Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2b9b54c
feat: add cross-platform subscriptionBillingIssue event
hyochan Apr 15, 2026
787d04a
fix(kmp-iap): override subscriptionBillingIssue + add cross-platform …
hyochan Apr 15, 2026
e2b60cf
fix(review): address PR 99 review comments
hyochan Apr 15, 2026
f4d360c
feat(rn-iap): wire subscriptionBillingIssue listener (Nitro + JS + iO…
hyochan Apr 15, 2026
1dae662
feat(expo-iap): wire subscriptionBillingIssue event (JS + iOS + Android)
hyochan Apr 15, 2026
2f4fb1c
feat(flutter): wire subscriptionBillingIssue stream (Dart + iOS + And…
hyochan Apr 15, 2026
2adeabc
feat(godot-iap): wire subscription_billing_issue signal (GDScript + A…
hyochan Apr 15, 2026
0133b8a
feat(kmp-iap): wire iOS subscriptionBillingIssue Flow via cinterop
hyochan Apr 15, 2026
f98c479
docs(docs-site): add Subscription Billing Issue feature page
hyochan Apr 15, 2026
79c0a15
feat(examples,hook): onSubscriptionBillingIssue hook + example banners
hyochan Apr 15, 2026
8633c3b
test,docs: Horizon no-op Robolectric test + sandbox E2E guide
hyochan Apr 15, 2026
ca20da6
fix(apple-example): unwrap iOS variant from Purchase in billing-issue…
hyochan Apr 15, 2026
592308e
fix(review): address 24 PR 99 review threads (Copilot + CodeRabbit)
hyochan Apr 15, 2026
94c77a8
test(rn-iap): add addSubscriptionBillingIssueListener to jest mocks
hyochan Apr 15, 2026
7548934
fix: address 4 codex review findings + tests + docs home expansion
hyochan Apr 16, 2026
1d6109d
fix(apple): add debug logging to Message.messages listener for sandbo…
hyochan Apr 16, 2026
488c3e7
fix(review): address 6 unresolved PR 99 review threads
hyochan Apr 16, 2026
24b4fa9
docs: update sandbox billing-issue guide with Apple's official method
hyochan Apr 16, 2026
5e5bfdf
fix(review): address 6 new PR 99 review threads
hyochan Apr 16, 2026
04fe84b
fix(review): address 3 PR 99 review threads
hyochan Apr 16, 2026
e0d6335
style(docs): prettier format subscription-billing-issue.tsx
hyochan Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
734 changes: 686 additions & 48 deletions knowledge/_claude-context/context.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions knowledge/external/google-billing-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,58 @@ val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
| `DEFERRED` | Deferred, no charge |
| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) |

## External Payments Program (8.3+)

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.

### Enable Developer Billing Option

```kotlin
// During BillingClient setup
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
.enableAutoServiceReconnection()
.enableDeveloperBillingOption(
DeveloperBillingOptionParams.newBuilder()
.setDeveloperProvidedBillingListener(developerBillingListener)
.build()
)
.build()
```

### DeveloperProvidedBillingListener

```kotlin
val developerBillingListener = DeveloperProvidedBillingListener {
userInitiatedBillingDetails ->
// User chose the developer-provided billing flow.
// Launch your external payment UI here.
}
```

### Launch Purchase with External Payments Option

```kotlin
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+
.build()

billingClient.launchBillingFlow(activity, params)
```

### Key Types (8.3+)

| Type | Purpose |
|------|---------|
| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` |
| `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing |
| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation |
| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments |

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

## Best Practices

1. **Always acknowledge purchases** within 3 days or they will be refunded
Expand Down
9 changes: 5 additions & 4 deletions knowledge/external/horizon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Meta Horizon provides IAP functionality for Quest VR applications. There are two main integration paths:

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

## Version Compatibility Matrix

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

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

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

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

### VerifyPurchaseResultHorizon

```typescript
Expand Down
111 changes: 96 additions & 15 deletions knowledge/external/storekit2-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ This document provides external API reference for Apple's StoreKit 2 framework.
| Feature | iOS Version | Description |
|---------|-------------|-------------|
| Win-back offers | iOS 18.0 | Re-engage churned subscribers |
| Consumable transaction history | iOS 18.0 | History includes finished consumables |
| Billing issue messages | iOS 18.0 | Automatic billing issue notifications via StoreKit Message |
| `eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase |
| Consumable transaction history | iOS 18.0 | Opt-in via `SK2ConsumableTransactionHistory` Info.plist key |
| StoreKit `Message` API | iOS 18.0 | Listener for billing issues, win-back, price increase, generic |
| UI context for purchases | iOS 18.2 | Required for proper payment sheet display |
| External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` |
| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` |
Comment thread
hyochan marked this conversation as resolved.
| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) |
| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) |
| `Offer.Period` | iOS 18.4 | Offer period information |
| `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data |
| Expanded offer codes | iOS 18.4 | For consumables/non-consumables |
| `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction |
| `Transaction.advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data on Transaction |
| `Transaction.appTransactionID` | iOS 18.4 | Per-Apple-Account identifier on Transaction |
| Expanded offer codes | iOS 18.4 | Offer codes for consumables/non-consumables |
| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format |
| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option |
| `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` |

### WWDC 2025 Updates

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

## appAccountToken

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

### Checking Eligibility

Discover eligible win-back offers before purchase via `Product.SubscriptionInfo.eligibleWinBackOfferIDs` (iOS 18+):

```swift
// Win-back offers are available in subscription.promotionalOffers
// with type == .winBack
let winBackOffers = product.subscription?.promotionalOffers.filter {
$0.type == .winBack
let status = try await product.subscription?.status.first
guard let renewalInfo = try status?.renewalInfo.payloadValue else { return }

// iOS 18+: offer IDs the current Apple Account is eligible for
let eligibleIDs = renewalInfo.eligibleWinBackOfferIDs
let eligibleOffers = (product.subscription?.promotionalOffers ?? []).filter {
$0.type == .winBack && eligibleIDs.contains($0.id ?? "")
}
```

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

## Transaction Updates (iOS 18.4+)

iOS 18.4 added three new read-only properties to `Transaction` (not just `AppTransaction`):

```swift
let transaction: Transaction

// iOS 18.4+ — all back-deployed to iOS 15
let txAppTransactionID = transaction.appTransactionID // Apple Account identifier
let offerPeriod = transaction.offerPeriod // Offer.Period?
let advancedCommerce = transaction.advancedCommerceInfo // AdvancedCommerceInfo?
```

| Property | Type | Notes |
|----------|------|-------|
| `appTransactionID` | String | Mirrors AppTransaction's identifier |
| `offerPeriod` | Offer.Period? | Phase of the promotional/intro offer |
| `advancedCommerceInfo` | AdvancedCommerceInfo? | Present for Advanced Commerce SKUs only |

## Advanced Commerce API (iOS 18.4+)

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

## External Purchase Support (iOS 18.2+)
## StoreKit Message API (iOS 18+)

Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic).

```swift
// Somewhere near app launch
Task {
for await message in Message.messages {
switch message.reason {
case .billingIssue:
// Show UI when user is ready; display from message.display(in:)
break
case .winBackOffer:
break
case .priceIncrease:
break
case .generic:
break
@unknown default:
break
}
}
}
```

| Reason | Trigger |
|--------|---------|
| `.billingIssue` | User has an unresolved billing problem on a subscription |
| `.priceIncrease` | Price change that requires user consent |
| `.winBackOffer` | User is eligible for a win-back offer |
| `.generic` | All other system-initiated messages |

> **OpenIAP Note**: To be surfaced by the cross-platform event layer — see `event.graphql` additions for message events.

## SubscriptionStatus by Transaction ID (WWDC 2025)

```swift
// WWDC 2025: look up status using any transactionID, not just a SKU
let status = try await Product.SubscriptionInfo.Status.status(for: transactionID)
```

## Consumable Transaction History (iOS 18+)

By default, `Transaction.all` omits finished consumables. Opt in by adding this key to **Info.plist**:

```xml
<key>SK2ConsumableTransactionHistory</key>
<true/>
```

With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`.

## External Purchase Support (iOS 17.4+)

`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+.

### Present External Purchase Notice

Expand Down
136 changes: 136 additions & 0 deletions knowledge/internal/sandbox-subscription-billing-issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: Sandbox E2E — subscriptionBillingIssue
audience: contributors, release QA
---

# Sandbox E2E: `subscriptionBillingIssue`

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.

All code paths verified by local compile + Horizon Robolectric unit test:

```bash
cd packages/google
./gradlew :openiap:compilePlayDebugKotlin
./gradlew :openiap:compileHorizonDebugKotlin
./gradlew :openiap:testHorizonDebugUnitTest # Robolectric no-op assertion

cd ../apple
swift build && swift test # 87 tests

cd ../../libraries/kmp-iap
./gradlew :library:compileDebugKotlinAndroid

cd ../react-native-iap && yarn typecheck
cd ../expo-iap && bun run tsc --noEmit
cd ../flutter_inapp_purchase && flutter analyze
```

---

## iOS (StoreKit 2 sandbox)

**Prereqs**

- 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).
- A sandbox Apple ID enrolled in App Store Connect → Users and Access → Sandbox Testers.
- 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).

**Step-by-step**

1. Sign the device out of its production Apple ID. Sign the sandbox tester into **Settings → App Store → Sandbox Account**.
2. Open the Example app:
- `packages/apple/Example/OpenIapExample.xcodeproj` — run the `OpenIapExample` scheme.
3. In-app: navigate to the **Subscription Flow** screen and subscribe to `dev.hyo.martie.premium`.
4. Force a billing issue on the **device** (requires iOS 16+ / iPadOS 16+):
- Go to **Settings → Developer → Sandbox Account → Manage → Account Settings**.
- Disable the **Allow Purchases & Renewals** setting.
- This causes all in-app purchases to fail and auto-renewable subscriptions to stop renewing.
- The setting applies to all devices the sandbox account signs in to.
- Reference: <https://developer.apple.com/documentation/storekit/testing-failing-subscription-renewals-and-in-app-purchases#Configure-the-sandbox-environment-to-simulate-billing-issues>.
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.
6. To simulate the user fixing the issue, re-enable **Allow Purchases & Renewals**.
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`.

**What success looks like**

- Console logs:

```text
🔔 [MessageListener] billingIssue received
Emitting subscriptionBillingIssue: dev.hyo.martie.premium
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- Banner visible on `SubscriptionFlowScreen`.
- `Transaction.currentEntitlements` shows the affected subscription in `.inBillingRetryPeriod` or `.inGracePeriod`.

**If nothing fires**

- iOS < 18 — silent no-op by design (confirm with `#available` trace in logs).
- tvOS / watchOS / native macOS (non-Catalyst) / visionOS build — silent no-op by design (StoreKit.Message API is iOS / Mac Catalyst only).
- App not foregrounded when the message is posted — StoreKit delivers on next `Message.messages` await; bring the app to foreground.

---

## Android (Play Billing 8.1+ sandbox)

**Prereqs**

- Physical Android device (or emulator with Play Store) running the Play flavor of the Example app:
`packages/google/Example` → run with product flavor **play**.
- A Play Console sandbox tester account on the device.
- A subscription product configured in the Play Console, matching `subscriptionSkus` in `SubscriptionFlowScreen.kt`.

**Step-by-step**

1. Install the Example APK (`./gradlew :Example:installPlayDebug`).
2. Sign in with the sandbox tester account in the Play Store app.
3. Subscribe to a test subscription in the Example app.
4. Force a suspension:
- In the **Google Play Store → Payment methods**, remove all payment methods for the sandbox account, OR
- Use Play Console → **Subscriptions → Test suspensions** (requires appropriate Play Console role). Reference: <https://developer.android.com/google/play/billing/subscriptions#suspended>.
5. Wait for Play's renewal cycle. When Play suspends the subscription, the next `getAvailablePurchases` or `onPurchasesUpdated` will include the purchase with `isSuspended == true`.
6. Return to the Example app. The banner fires once per session per affected purchase (deduped by `purchaseToken`).

**What success looks like**

- `logcat` shows:

```text
D OpenIapModule: onPurchasesUpdated isSuspended=true ...
D Example: subscriptionBillingIssue fired for sku=...
```

- Banner visible on `SubscriptionFlowScreen`.
- Tapping **Fix payment method** launches `deepLinkToSubscriptions` which routes to Play's subscription center.

**Horizon flavor (do NOT attempt)**

- The Horizon flavor's `addSubscriptionBillingIssueListener` is a documented no-op. Verified by
`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`.

---

## Cross-library smoke (optional)

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:

| Library | Check |
|---------|-------|
| react-native-iap | `useIAP({ onSubscriptionBillingIssue: p => console.log(p) })` fires the callback. `subscriptionBillingIssueListener()` also fires independently. |
| expo-iap | `subscriptionBillingIssueListener((p) => console.log(p))` fires via expo event emitter. |
| flutter_inapp_purchase | `iap.subscriptionBillingIssueListener.listen(...)` emits the Purchase. |
| godot-iap | `godot_iap.subscription_billing_issue.connect(...)` emits the Dictionary payload. |
| kmp-iap | `kmpIapInstance.subscriptionBillingIssueListener.collect {...}` emits in the Flow. |

---

## Automated coverage matrix

| Layer | Mechanism | Status |
|-------|-----------|--------|
| Horizon no-op guarantee | Robolectric unit test (`SubscriptionBillingIssueHorizonNoOpTest`) | Runs on CI |
| Play-flavor compile of listener surface | `compilePlayDebugKotlin` | Runs on CI |
| Apple Swift test fakes implement protocol | `swift test` | Runs on CI |
| Downstream types synced | Gen check by each library's typecheck task | Runs on CI |
| Live sandbox behavior (iOS 18 message + Play suspended) | Manual, this document | Release QA |
Loading
Loading