Skip to content

Commit f2caa3b

Browse files
hyochanclaude
andauthored
feat: add cross-platform subscriptionBillingIssue event (#99)
## Summary Unifies StoreKit 2's `Message.Reason.billingIssue` (iOS 18+ / Mac Catalyst 18+) and Google Play Billing's `Purchase.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 `useIAP` hook, a feature docs page, and a Horizon no-op unit test land in this PR. ## What's in this PR ### Schema (`packages/gql`) - New `IapEvent.SubscriptionBillingIssue` enum value in `type.graphql`. - New `subscriptionBillingIssue: Purchase!` field on `Subscription` in `event.graphql`. - All generated types regenerated: `Types.swift`, `Types.kt`, `types.ts`, `types.dart`, `types.gd`, and all 5 downstream library type mirrors. ### iOS (`packages/apple`) - `subscriptionBillingIssueListener` added to `OpenIapModuleProtocol`, `OpenIapModule`, `OpenIapModule+ObjC`, and `IapState`. - `startMessageListener()` spins up a `StoreKit.Message.messages` loop on init. Gated to iOS 18+ / Mac Catalyst 18+ (where `Message.Reason.billingIssue` exists). macOS / tvOS / watchOS / visionOS are silent no-ops (Message API not available on those platforms). - On `.billingIssue`, batches a single `StoreKit.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 `.inBillingRetryPeriod` or `.inGracePeriod`. ### Android Play flavor (`packages/google/.../play`) - `addSubscriptionBillingIssueListener` / `removeSubscriptionBillingIssueListener` implemented with thread-safe `CopyOnWriteArraySet` + `ConcurrentHashMap.newKeySet()` so add/remove on the main thread is safe against iteration from `Dispatchers.IO`. - Emission hook runs both in `getAvailablePurchases` (always queries `includeSuspended=true` so suspended purchases reach the notifier even when the caller opted not to include them in the returned list) and in `onPurchasesUpdated` (push path, parity with iOS). - Deduplicated per session by `purchaseToken`. ### Android Horizon flavor (`packages/google/.../horizon`) — explicit no-op + automated guarantee - Both `add`/`remove` are implemented as no-ops with `Log.w` warnings. Horizon Billing Compatibility SDK targets Play Billing 7.0 which does not expose `isSuspended`. 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 | Library | What landed | |---------|-------------| | **react-native-iap** | Nitro spec (`add/removeSubscriptionBillingIssueListener`), regenerated nitrogen bridge, Swift + Kotlin implementations, public `subscriptionBillingIssueListener()` JS API, **`useIAP({ onSubscriptionBillingIssue })` hook callback** | | **expo-iap** | `OpenIapEvent.SubscriptionBillingIssue` enum, `EventPayloads` mapping, `subscriptionBillingIssueListener()` JS API, Kotlin event emission via `ExpoIapHelper.setupListeners`, Swift event emission via `ExpoIapHelper.setupListeners` | | **flutter_inapp_purchase** | Dart `subscriptionBillingIssueListener` `Stream<Purchase>` on `FlutterInappPurchase`, Android method-channel forwarding, iOS method-channel forwarding | | **godot-iap** | GDScript `subscription_billing_issue(purchase)` signal, Android AAR `SignalInfo` + listener registration, iOS GDExtension `@Signal` + listener registration | | **kmp-iap** | `subscriptionBillingIssueListener: Flow<Purchase>` in `commonMain`, Android backing `MutableSharedFlow` + emission inside `getAvailablePurchasesHandler`, iOS backing flow + cinterop subscription via `openIapModule.addSubscriptionBillingIssueListener` | ### Example apps (manual-test ready) - **`packages/apple/Example`** — `SubscriptionFlowScreen` renders an orange "Subscription needs attention" banner with a `Fix payment method` button that calls `OpenIapModule.shared.deepLinkToSubscriptions(nil)`. Listener registered in `setupIapProvider`, torn down on screen dispose. - **`packages/google/Example`** — Compose LazyColumn gains an analogous banner registered via `DisposableEffect(iapStore)`, dispatching `iapStore.deepLinkToSubscriptions` with the failing purchase's SKU + package name. `OpenIapStore` now exposes `add/removeSubscriptionBillingIssueListener` pass-through so Compose callers don't have to reach the underlying `OpenIapProtocol`. ### Documentation - **Feature page**: `packages/docs/src/pages/docs/features/subscription-billing-issue.tsx` — new `/docs/features/subscription-billing-issue` route + sidebar entry. Platform-behavior table with min versions, recommended UX, per-language usage snippets (RN/expo, Flutter, Godot, KMP), deduping semantics. - **Release notes**: `packages/docs/src/pages/docs/updates/releases.tsx` entry for 2026-04-16. - **Sandbox E2E guide**: `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 using `libraries-versions.jsonc "local"` mode. - **External API docs drift fixes**: `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). - **llms**: `knowledge/_claude-context/context.md` + `packages/docs/public/llms{,-full}.txt` recompiled. ## Verification (every target compiled locally before push) | Target | Command | Result | |--------|---------|--------| | apple package | `swift build` + `swift test` (87 tests) | OK | | google Play | `gradlew :openiap:compilePlayDebugKotlin` | OK | | google Horizon | `gradlew :openiap:compileHorizonDebugKotlin` | OK | | google Horizon test | `gradlew :openiap:testHorizonDebugUnitTest` | OK (Robolectric no-op assertion passes) | | google Example | `gradlew :example:compilePlayDebugKotlin` | OK | | react-native-iap | `yarn specs` + `yarn tsc --noEmit -p tsconfig.build.json` | OK | | expo-iap | `bun run tsc --noEmit` | OK | | flutter_inapp_purchase | `flutter analyze` | OK | | kmp-iap | `gradlew :library:compileCommonMainKotlinMetadata compileDebugKotlinAndroid` | OK | | docs | `tsc --noEmit` + `prettier --check` | OK | CI 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 - [x] Regenerate types in all 5 downstream libraries and confirm compile still green (rn-iap + expo-iap typecheck, flutter analyze, kmp-iap compile, apple swift test — all green locally and on CI). - [x] Horizon flavor exposes the listener as an explicit no-op: automated by `SubscriptionBillingIssueHorizonNoOpTest` (Robolectric). CI runs `:openiap:testHorizonDebugUnitTest`. - [x] Horizon + Play flavors both compile with the new public API. - [x] `deepLinkToSubscriptions` flow resolves on both platforms (existing API, unchanged; referenced from the new feature page and wired in both example app banners). - [ ] Live iOS 18 sandbox billing-issue message — manual run by release QA. Procedure documented in `knowledge/internal/sandbox-subscription-billing-issue.md`. - [ ] Live Play 8.1+ sandbox suspended subscription — manual run by release QA. Same document, Android section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Cross-platform "subscription billing issue" event: notifies when active subscriptions need user attention (iOS 18+, Play Billing 8.1+). Exposed across Expo, React Native, Flutter, Godot, KMP and examples include a user-facing "Subscription needs attention" banner and listener APIs. * **Documentation** * New feature guide, API listener usage, recommended UX (deep-linking to subscription management), sandbox QA workflow, and release-note entry. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0cffffd commit f2caa3b

75 files changed

Lines changed: 5124 additions & 607 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

knowledge/_claude-context/context.md

Lines changed: 686 additions & 48 deletions
Large diffs are not rendered by default.

knowledge/external/google-billing-api.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,58 @@ val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
375375
| `DEFERRED` | Deferred, no charge |
376376
| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) |
377377

378+
## External Payments Program (8.3+)
379+
380+
Billing Library 8.3 (December 2025) added support for the External Payments program (Japan-only, as of launch). Developers enrolled in the program can offer alternative payment methods alongside Google Play billing.
381+
382+
### Enable Developer Billing Option
383+
384+
```kotlin
385+
// During BillingClient setup
386+
val billingClient = BillingClient.newBuilder(context)
387+
.setListener(purchasesUpdatedListener)
388+
.enablePendingPurchases()
389+
.enableAutoServiceReconnection()
390+
.enableDeveloperBillingOption(
391+
DeveloperBillingOptionParams.newBuilder()
392+
.setDeveloperProvidedBillingListener(developerBillingListener)
393+
.build()
394+
)
395+
.build()
396+
```
397+
398+
### DeveloperProvidedBillingListener
399+
400+
```kotlin
401+
val developerBillingListener = DeveloperProvidedBillingListener {
402+
userInitiatedBillingDetails ->
403+
// User chose the developer-provided billing flow.
404+
// Launch your external payment UI here.
405+
}
406+
```
407+
408+
### Launch Purchase with External Payments Option
409+
410+
```kotlin
411+
val params = BillingFlowParams.newBuilder()
412+
.setProductDetailsParamsList(listOf(productDetailsParams))
413+
.setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+
414+
.build()
415+
416+
billingClient.launchBillingFlow(activity, params)
417+
```
418+
419+
### Key Types (8.3+)
420+
421+
| Type | Purpose |
422+
|------|---------|
423+
| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` |
424+
| `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing |
425+
| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation |
426+
| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments |
427+
428+
> **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this.
429+
378430
## Best Practices
379431

380432
1. **Always acknowledge purchases** within 3 days or they will be refunded

knowledge/external/horizon-api.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Meta Horizon provides IAP functionality for Quest VR applications. There are two main integration paths:
99

1010
1. **Platform SDK IAP** - Native Horizon IAP APIs
11-
2. **Billing Compatibility SDK** - Google Play Billing Library compatible wrapper
11+
2. **Billing Compatibility SDK** - Google Play Billing Library-compatible wrapper
1212

1313
## Version Compatibility Matrix
1414

@@ -37,7 +37,7 @@ When writing shared code for both Play and Horizon flavors:
3737

3838
### APIs Only in Billing 8.x (DO NOT use in shared code)
3939

40-
- `enableAutoServiceReconnection()` - Auto reconnect feature (8.0+)
40+
- `enableAutoServiceReconnection()` - Auto-reconnect feature (8.0+)
4141
- Product-level status codes in `queryProductDetailsAsync()` response (8.0+)
4242
- One-time products with multiple offers (8.0+)
4343
- Sub-response codes in `BillingResult` (8.0+)
@@ -192,11 +192,12 @@ Mark consumable item as used (required for re-purchase).
192192
interface VerifyPurchaseHorizonOptions {
193193
userId: string; // Horizon user ID
194194
sku: string; // Product SKU
195-
appId: string; // Horizon App ID
196-
appSecret: string; // Horizon App Secret
195+
accessToken: string; // Format: "OC|APP_ID|APP_SECRET"
197196
}
198197
```
199198

199+
> **OpenIAP Note**: The GraphQL schema takes a single `accessToken` formatted as `OC|APP_ID|APP_SECRET` rather than separate `appId` / `appSecret` fields. Build the token server-side and pass it as one string.
200+
200201
### VerifyPurchaseResultHorizon
201202

202203
```typescript

knowledge/external/storekit2-api.md

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,27 @@ This document provides external API reference for Apple's StoreKit 2 framework.
77
| Feature | iOS Version | Description |
88
|---------|-------------|-------------|
99
| Win-back offers | iOS 18.0 | Re-engage churned subscribers |
10-
| Consumable transaction history | iOS 18.0 | History includes finished consumables |
11-
| Billing issue messages | iOS 18.0 | Automatic billing issue notifications via StoreKit Message |
10+
| `eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase |
11+
| Consumable transaction history | iOS 18.0 | Opt-in via `SK2ConsumableTransactionHistory` Info.plist key |
12+
| StoreKit `Message` API | iOS 18.0 | Listener for billing issues, win-back, price increase, generic |
1213
| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
13-
| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
14+
| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` |
1415
| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
1516
| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
16-
| `Offer.Period` | iOS 18.4 | Offer period information |
17-
| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
18-
| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
17+
| `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction |
18+
| `Transaction.advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data on Transaction |
19+
| `Transaction.appTransactionID` | iOS 18.4 | Per-Apple-Account identifier on Transaction |
20+
| Expanded offer codes | iOS 18.4 | Offer codes for consumables/non-consumables |
1921
| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format |
2022
| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option |
23+
| `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` |
2124

2225
### WWDC 2025 Updates
2326

24-
- **SubscriptionStatus by Transaction ID**: Query subscription status using any transaction ID
25-
- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string
26-
- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option
27-
- Both new purchase options are back-deployed to iOS 15
27+
- **SubscriptionStatus by Transaction ID**: `SubscriptionInfo.Status.status(for: transactionID:)` accepts any transaction ID, not just SKU.
28+
- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string.
29+
- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option.
30+
- Both new purchase options are back-deployed to iOS 15.
2831

2932
## appAccountToken
3033

@@ -218,11 +221,16 @@ let result = try await product.purchase(options: [
218221

219222
### Checking Eligibility
220223

224+
Discover eligible win-back offers before purchase via `Product.SubscriptionInfo.eligibleWinBackOfferIDs` (iOS 18+):
225+
221226
```swift
222-
// Win-back offers are available in subscription.promotionalOffers
223-
// with type == .winBack
224-
let winBackOffers = product.subscription?.promotionalOffers.filter {
225-
$0.type == .winBack
227+
let status = try await product.subscription?.status.first
228+
guard let renewalInfo = try status?.renewalInfo.payloadValue else { return }
229+
230+
// iOS 18+: offer IDs the current Apple Account is eligible for
231+
let eligibleIDs = renewalInfo.eligibleWinBackOfferIDs
232+
let eligibleOffers = (product.subscription?.promotionalOffers ?? []).filter {
233+
$0.type == .winBack && eligibleIDs.contains($0.id ?? "")
226234
}
227235
```
228236

@@ -272,6 +280,25 @@ let originalPlatform = appTransaction.originalPlatform // Original purchase pl
272280
- Works with Family Sharing (each family member gets unique ID)
273281
- Back-deployed to iOS 15
274282

283+
## Transaction Updates (iOS 18.4+)
284+
285+
iOS 18.4 added three new read-only properties to `Transaction` (not just `AppTransaction`):
286+
287+
```swift
288+
let transaction: Transaction
289+
290+
// iOS 18.4+ — all back-deployed to iOS 15
291+
let txAppTransactionID = transaction.appTransactionID // Apple Account identifier
292+
let offerPeriod = transaction.offerPeriod // Offer.Period?
293+
let advancedCommerce = transaction.advancedCommerceInfo // AdvancedCommerceInfo?
294+
```
295+
296+
| Property | Type | Notes |
297+
|----------|------|-------|
298+
| `appTransactionID` | String | Mirrors AppTransaction's identifier |
299+
| `offerPeriod` | Offer.Period? | Phase of the promotional/intro offer |
300+
| `advancedCommerceInfo` | AdvancedCommerceInfo? | Present for Advanced Commerce SKUs only |
301+
275302
## Advanced Commerce API (iOS 18.4+)
276303

277304
For apps with large product catalogs:
@@ -283,7 +310,61 @@ if let advancedInfo = product.advancedCommerceInfo {
283310
}
284311
```
285312

286-
## External Purchase Support (iOS 18.2+)
313+
## StoreKit Message API (iOS 18+)
314+
315+
Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic).
316+
317+
```swift
318+
// Somewhere near app launch
319+
Task {
320+
for await message in Message.messages {
321+
switch message.reason {
322+
case .billingIssue:
323+
// Show UI when user is ready; display from message.display(in:)
324+
break
325+
case .winBackOffer:
326+
break
327+
case .priceIncrease:
328+
break
329+
case .generic:
330+
break
331+
@unknown default:
332+
break
333+
}
334+
}
335+
}
336+
```
337+
338+
| Reason | Trigger |
339+
|--------|---------|
340+
| `.billingIssue` | User has an unresolved billing problem on a subscription |
341+
| `.priceIncrease` | Price change that requires user consent |
342+
| `.winBackOffer` | User is eligible for a win-back offer |
343+
| `.generic` | All other system-initiated messages |
344+
345+
> **OpenIAP Note**: To be surfaced by the cross-platform event layer — see `event.graphql` additions for message events.
346+
347+
## SubscriptionStatus by Transaction ID (WWDC 2025)
348+
349+
```swift
350+
// WWDC 2025: look up status using any transactionID, not just a SKU
351+
let status = try await Product.SubscriptionInfo.Status.status(for: transactionID)
352+
```
353+
354+
## Consumable Transaction History (iOS 18+)
355+
356+
By default, `Transaction.all` omits finished consumables. Opt in by adding this key to **Info.plist**:
357+
358+
```xml
359+
<key>SK2ConsumableTransactionHistory</key>
360+
<true/>
361+
```
362+
363+
With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`.
364+
365+
## External Purchase Support (iOS 17.4+)
366+
367+
`ExternalPurchase.presentNoticeSheet()` / `ExternalPurchase.open(url:)` ship on iOS 17.4+. The follow-on custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+.
287368

288369
### Present External Purchase Notice
289370

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
title: Sandbox E2E — subscriptionBillingIssue
3+
audience: contributors, release QA
4+
---
5+
6+
# Sandbox E2E: `subscriptionBillingIssue`
7+
8+
The `subscriptionBillingIssue` event requires live store signals that cannot be produced from a unit-test JVM. This document captures the exact sandbox procedure for both platforms so any reviewer can reproduce.
9+
10+
All code paths verified by local compile + Horizon Robolectric unit test:
11+
12+
```bash
13+
cd packages/google
14+
./gradlew :openiap:compilePlayDebugKotlin
15+
./gradlew :openiap:compileHorizonDebugKotlin
16+
./gradlew :openiap:testHorizonDebugUnitTest # Robolectric no-op assertion
17+
18+
cd ../apple
19+
swift build && swift test # 87 tests
20+
21+
cd ../../libraries/kmp-iap
22+
./gradlew :library:compileDebugKotlinAndroid
23+
24+
cd ../react-native-iap && yarn typecheck
25+
cd ../expo-iap && bun run tsc --noEmit
26+
cd ../flutter_inapp_purchase && flutter analyze
27+
```
28+
29+
---
30+
31+
## iOS (StoreKit 2 sandbox)
32+
33+
**Prereqs**
34+
35+
- Physical iOS device running **iOS 18.0 or later** (the `Message.Reason.billingIssue` API is iOS 18+ / Mac Catalyst 18+; the iOS Simulator does not deliver StoreKit Messages).
36+
- A sandbox Apple ID enrolled in App Store Connect → Users and Access → Sandbox Testers.
37+
- An auto-renewable subscription product configured on App Store Connect, and the Example project's `subscriptionIds` list pointing at it (`dev.hyo.martie.premium` by default).
38+
39+
**Step-by-step**
40+
41+
1. Sign the device out of its production Apple ID. Sign the sandbox tester into **Settings → App Store → Sandbox Account**.
42+
2. Open the Example app:
43+
- `packages/apple/Example/OpenIapExample.xcodeproj` — run the `OpenIapExample` scheme.
44+
3. In-app: navigate to the **Subscription Flow** screen and subscribe to `dev.hyo.martie.premium`.
45+
4. Force a billing issue on the **device** (requires iOS 16+ / iPadOS 16+):
46+
- Go to **Settings → Developer → Sandbox Account → Manage → Account Settings**.
47+
- Disable the **Allow Purchases & Renewals** setting.
48+
- This causes all in-app purchases to fail and auto-renewable subscriptions to stop renewing.
49+
- The setting applies to all devices the sandbox account signs in to.
50+
- Reference: <https://developer.apple.com/documentation/storekit/testing-failing-subscription-renewals-and-in-app-purchases#Configure-the-sandbox-environment-to-simulate-billing-issues>.
51+
5. Wait for the next renewal cycle (Renewal Rate = 5 minutes → wait ~5 min). The renewal fails, and StoreKit delivers `Message.Reason.billingIssue` when the app is in the foreground.
52+
6. To simulate the user fixing the issue, re-enable **Allow Purchases & Renewals**.
53+
7. Expected UI: the orange "Subscription needs attention" banner appears at the top of the Subscription Flow screen. Tapping **Fix payment method** opens `SKPaymentQueue` / `showManageSubscriptions`.
54+
55+
**What success looks like**
56+
57+
- Console logs:
58+
59+
```text
60+
🔔 [MessageListener] billingIssue received
61+
Emitting subscriptionBillingIssue: dev.hyo.martie.premium
62+
```
63+
64+
- Banner visible on `SubscriptionFlowScreen`.
65+
- `Transaction.currentEntitlements` shows the affected subscription in `.inBillingRetryPeriod` or `.inGracePeriod`.
66+
67+
**If nothing fires**
68+
69+
- iOS < 18 — silent no-op by design (confirm with `#available` trace in logs).
70+
- tvOS / watchOS / native macOS (non-Catalyst) / visionOS build — silent no-op by design (StoreKit.Message API is iOS / Mac Catalyst only).
71+
- App not foregrounded when the message is posted — StoreKit delivers on next `Message.messages` await; bring the app to foreground.
72+
73+
---
74+
75+
## Android (Play Billing 8.1+ sandbox)
76+
77+
**Prereqs**
78+
79+
- Physical Android device (or emulator with Play Store) running the Play flavor of the Example app:
80+
`packages/google/Example` → run with product flavor **play**.
81+
- A Play Console sandbox tester account on the device.
82+
- A subscription product configured in the Play Console, matching `subscriptionSkus` in `SubscriptionFlowScreen.kt`.
83+
84+
**Step-by-step**
85+
86+
1. Install the Example APK (`./gradlew :Example:installPlayDebug`).
87+
2. Sign in with the sandbox tester account in the Play Store app.
88+
3. Subscribe to a test subscription in the Example app.
89+
4. Force a suspension:
90+
- In the **Google Play Store → Payment methods**, remove all payment methods for the sandbox account, OR
91+
- Use Play Console → **Subscriptions → Test suspensions** (requires appropriate Play Console role). Reference: <https://developer.android.com/google/play/billing/subscriptions#suspended>.
92+
5. Wait for Play's renewal cycle. When Play suspends the subscription, the next `getAvailablePurchases` or `onPurchasesUpdated` will include the purchase with `isSuspended == true`.
93+
6. Return to the Example app. The banner fires once per session per affected purchase (deduped by `purchaseToken`).
94+
95+
**What success looks like**
96+
97+
- `logcat` shows:
98+
99+
```text
100+
D OpenIapModule: onPurchasesUpdated isSuspended=true ...
101+
D Example: subscriptionBillingIssue fired for sku=...
102+
```
103+
104+
- Banner visible on `SubscriptionFlowScreen`.
105+
- Tapping **Fix payment method** launches `deepLinkToSubscriptions` which routes to Play's subscription center.
106+
107+
**Horizon flavor (do NOT attempt)**
108+
109+
- The Horizon flavor's `addSubscriptionBillingIssueListener` is a documented no-op. Verified by
110+
`SubscriptionBillingIssueHorizonNoOpTest` (Robolectric, runs on CI). There is no sandbox path on Horizon because the Billing Compatibility SDK 1.1.1 targets Play Billing 7.0 which does not expose `Purchase.isSuspended`.
111+
112+
---
113+
114+
## Cross-library smoke (optional)
115+
116+
Use `libraries-versions.jsonc` to point example apps at the local monorepo sources (already `"local"` by default), then verify each downstream library surfaces the event:
117+
118+
| Library | Check |
119+
|---------|-------|
120+
| react-native-iap | `useIAP({ onSubscriptionBillingIssue: p => console.log(p) })` fires the callback. `subscriptionBillingIssueListener()` also fires independently. |
121+
| expo-iap | `subscriptionBillingIssueListener((p) => console.log(p))` fires via expo event emitter. |
122+
| flutter_inapp_purchase | `iap.subscriptionBillingIssueListener.listen(...)` emits the Purchase. |
123+
| godot-iap | `godot_iap.subscription_billing_issue.connect(...)` emits the Dictionary payload. |
124+
| kmp-iap | `kmpIapInstance.subscriptionBillingIssueListener.collect {...}` emits in the Flow. |
125+
126+
---
127+
128+
## Automated coverage matrix
129+
130+
| Layer | Mechanism | Status |
131+
|-------|-----------|--------|
132+
| Horizon no-op guarantee | Robolectric unit test (`SubscriptionBillingIssueHorizonNoOpTest`) | Runs on CI |
133+
| Play-flavor compile of listener surface | `compilePlayDebugKotlin` | Runs on CI |
134+
| Apple Swift test fakes implement protocol | `swift test` | Runs on CI |
135+
| Downstream types synced | Gen check by each library's typecheck task | Runs on CI |
136+
| Live sandbox behavior (iOS 18 message + Play suspended) | Manual, this document | Release QA |

0 commit comments

Comments
 (0)