From 2b9b54c21484f641f786fbc4a8bd4fc3a1a66a18 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 00:49:35 +0900 Subject: [PATCH 01/21] feat: add cross-platform subscriptionBillingIssue event 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) --- knowledge/_claude-context/context.md | 592 ++++- knowledge/external/google-billing-api.md | 52 + knowledge/external/horizon-api.md | 5 +- knowledge/external/storekit2-api.md | 107 +- libraries/expo-iap/src/types.ts | 17 +- .../flutter_inapp_purchase/lib/types.dart | 24 +- libraries/godot-iap/addons/godot-iap/types.gd | 8 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 28 +- libraries/react-native-iap/src/types.ts | 17 +- packages/apple/Sources/Helpers/IapState.swift | 10 + packages/apple/Sources/Models/Types.swift | 21 + .../apple/Sources/OpenIapModule+ObjC.swift | 8 + packages/apple/Sources/OpenIapModule.swift | 77 +- packages/apple/Sources/OpenIapProtocol.swift | 7 + packages/docs/public/llms-full.txt | 2301 +++++++++++++---- packages/docs/public/llms.txt | 2 +- .../docs/src/pages/docs/updates/releases.tsx | 86 + .../java/dev/hyo/openiap/OpenIapModule.kt | 12 + .../java/dev/hyo/openiap/OpenIapProtocol.kt | 13 + .../src/main/java/dev/hyo/openiap/Types.kt | 27 +- .../hyo/openiap/listener/OpenIapListener.kt | 17 + .../java/dev/hyo/openiap/OpenIapModule.kt | 38 +- packages/gql/src/event.graphql | 14 + packages/gql/src/generated/Types.kt | 28 +- packages/gql/src/generated/Types.swift | 21 + packages/gql/src/generated/types.dart | 24 +- packages/gql/src/generated/types.gd | 8 +- packages/gql/src/generated/types.ts | 17 +- packages/gql/src/type.graphql | 7 + 29 files changed, 3001 insertions(+), 587 deletions(-) diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 0cdbecd6..780536e8 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-01-20T21:43:18.692Z +> Last updated: 2026-04-15T15:48:40.902Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -265,6 +265,12 @@ openiap/ │ ├── gql/ # GraphQL schema & type generation │ ├── google/ # Android library (Kotlin) │ └── apple/ # iOS/macOS library (Swift) +├── libraries/ # Framework SDK implementations +│ ├── react-native-iap/ # React Native (npm, Yarn 3, Nitro Modules) +│ ├── expo-iap/ # Expo (npm, Bun, Expo Modules) +│ ├── flutter_inapp_purchase/ # Flutter (pub.dev, Dart) +│ ├── godot-iap/ # Godot 4.x (GitHub Release, GDScript) +│ └── kmp-iap/ # Kotlin Multiplatform (Maven Central) ├── knowledge/ # Shared knowledge base (SSOT) │ ├── internal/ # Project philosophy (HIGHEST PRIORITY) │ ├── external/ # External API reference @@ -274,6 +280,8 @@ openiap/ └── .github/workflows/ # CI/CD workflows ``` +Libraries reference local `packages/apple` and `packages/google` source directly (not published CocoaPods/Maven artifacts), enabling immediate development without waiting for native releases. + ## Package Responsibilities ### packages/gql @@ -919,6 +927,77 @@ Meta Horizon has different APIs from Google Play: --- +## Cross-Library Verification for Shared-Package Changes (MANDATORY) + +> **When:** any change to `packages/google` or `packages/apple` that modifies +> a **public** API surface (class/struct shape, enum cases, function +> signatures, exception/error types). Adding a new field, removing a +> singleton, renaming a method, or adding an enum entry all qualify. + +The compiled `packages/google` artifact is consumed as a **native +dependency** by every framework library. A change that compiles inside +`packages/google` alone can still break downstream libraries whose +Kotlin (or Swift) code references the affected symbol. + +Before committing any change that touches the following surfaces: + +- `packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt` +- `packages/gql/src/error.graphql` (ErrorCode enum additions — ripples + through every generated `Types.*`) +- `packages/apple/Sources/Models/OpenIapError.swift` +- `packages/apple/Sources/OpenIapModule.swift` (public function + signatures) + +you **must** run the downstream compile for every framework library: + +```bash +# Android (Google) downstream compile — required for every PR that +# touches packages/google public API +cd libraries/flutter_inapp_purchase && flutter analyze && flutter test +cd libraries/react-native-iap/example/android && ./gradlew :react-native-iap:compileDebugKotlin +cd libraries/expo-iap/example/android && ./gradlew :expo-iap:compileDebugKotlin +cd libraries/kmp-iap && ./gradlew :library:build -x test + +# iOS (Apple) downstream compile — framework libraries consume +# openiap-apple through CocoaPods / SPM, so swift build on the source +# package is the minimum; add library-side Xcode builds when the +# change is non-additive. +cd packages/apple && swift build && swift test --filter OpenIapTests +``` + +### Mechanical grep guard + +Right after changing `OpenIapError.kt`, run this grep to catch stale +singleton references that will fail in downstream compiles: + +```bash +grep -rnE "OpenIap(API)?Error\.(DeveloperError|PurchaseFailed|UserCancelled|ServiceUnavailable|BillingUnavailable|ItemUnavailable|BillingError|ItemAlreadyOwned|ItemNotOwned|ServiceDisconnected|FeatureNotSupported|ServiceTimeout|UnknownError)\b" libraries/ packages/google/ \ + | grep -vE "\.(CODE|MESSAGE|Companion|rawValue)" \ + | grep -vE "is Open" \ + | grep -vE "\(" +``` + +Any hit is a call site that uses a now-data-class name without `()` and +will fail to compile — add the parentheses (or the concrete +`debugMessage` argument) before pushing. + +### Cross-library SemVer coordination + +Breaking a shared-package API (e.g. `object → data class` on +`OpenIapError`) forces a **major** bump on that package (2.0.0) and +cascades into downstream libraries: + +| Change in shared package | Google/Apple bump | Downstream bump | +| ----------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ | +| Add optional field to a type | minor | minor | +| Add a new enum case | major (Swift/Kotlin exhaustive switches break) | minor | +| `object` → `data class` / renamed method | major | minor (downstream pins to new major; own API unchanged) | + +Release order MUST be: shared packages first (so downstream libraries +can depend on the new version), then framework libraries in any order. + +--- + ## GQL Package (packages/gql) ### Required Pre-Work @@ -926,29 +1005,129 @@ Meta Horizon has different APIs from Google Play: Before writing or editing anything, **ALWAYS** review: - [`packages/gql/CONVENTION.md`](../../packages/gql/CONVENTION.md) +### Code Generation Architecture + +The GQL package uses an **IR-based (Intermediate Representation) code generation system**: + +```text +GraphQL Schema (src/*.graphql) + ↓ + [1] Parser (codegen/core/parser.ts) + ↓ + [2] Transformer → IR (codegen/core/transformer.ts) + ↓ + [3] Language Plugins (codegen/plugins/*.ts) + ↓ + Generated Files (src/generated/*) +``` + +#### Directory Structure + +```text +packages/gql/codegen/ +├── index.ts # Main entry point +├── core/ +│ ├── types.ts # IR type definitions +│ ├── parser.ts # GraphQL schema parser +│ ├── transformer.ts # AST → IR transformer +│ └── utils.ts # Common utilities (case conversion, keywords) +├── plugins/ +│ ├── base-plugin.ts # Abstract base class +│ ├── swift.ts # Swift plugin (Codable, ErrorCode handling) +│ ├── kotlin.ts # Kotlin plugin (sealed interface, fromJson/toJson) +│ ├── dart.ts # Dart plugin (sealed class, factory constructors) +│ └── gdscript.ts # GDScript plugin (Godot engine) +└── templates/ # Handlebars templates (optional) +``` + +#### IR (Intermediate Representation) + +The IR is a language-agnostic representation of the GraphQL schema: + +| IR Type | Description | +|---------|-------------| +| `IREnum` | Enum with values, raw values, legacy aliases | +| `IRInterface` | Protocol/Interface with fields | +| `IRObject` | Struct/Class with fields, implements, unions | +| `IRInput` | Input type with fields, required field tracking | +| `IRUnion` | Union with members, nested union handling | +| `IROperation` | Query/Mutation/Subscription with fields | + +#### Language Plugins + +Each plugin handles language-specific requirements: + +| Plugin | Features | +|--------|----------| +| **Swift** | Codable protocol, ErrorCode custom initializer, platform defaults | +| **Kotlin** | sealed interface, fromJson/toJson with nullable patterns | +| **Dart** | extends/implements, factory constructors, sealed class | +| **GDScript** | _init(), from_json/to_json, Variant type | + ### Scripts | Script | Description | |--------|-------------| -| `generate:ts` | Generate TypeScript types | -| `generate:swift` | Generate Swift types | -| `generate:kotlin` | Generate Kotlin types | -| `generate:dart` | Generate Dart types | -| `generate` | Generate all types | +| `generate:ts` | Generate TypeScript types (graphql-codegen) | +| `generate:swift` | Generate Swift types (IR-based plugin) | +| `generate:kotlin` | Generate Kotlin types (IR-based plugin) | +| `generate:dart` | Generate Dart types (IR-based plugin) | +| `generate:gdscript` | Generate GDScript types (IR-based plugin) | +| `generate` | Generate all types + sync to platforms | | `sync` | Sync generated types to platform packages | ### Generating Types ```bash cd packages/gql + +# Generate all platform types bun run generate + +# Generate specific platform +bun run generate:swift +bun run generate:kotlin +bun run generate:dart +bun run generate:gdscript ``` -This generates: -- TypeScript types: `src/generated/types.ts` -- Swift types: `dist/swift/Types.swift` -- Kotlin types: `dist/kotlin/Types.kt` -- Dart types: `dist/dart/types.dart` +### Generated Files + +| File | Platform | Description | +|------|----------|-------------| +| `src/generated/types.ts` | TypeScript | Type definitions | +| `src/generated/Types.swift` | iOS/macOS | Codable structs & enums | +| `src/generated/Types.kt` | Android | Data classes & sealed interfaces | +| `src/generated/types.dart` | Flutter | Classes & sealed classes | +| `src/generated/types.gd` | Godot | GDScript classes | + +### Adding a New Language + +1. Create `codegen/plugins/.ts` extending `CodegenPlugin` +2. Implement abstract methods: + - `mapScalar()` - Map GraphQL scalars to language types + - `mapType()` - Map IR types to language type strings + - `generateEnum()`, `generateObject()`, etc. +3. Register in `codegen/index.ts` +4. Add script to `package.json` + +### Schema Markers + +Special comments in GraphQL SDL trigger codegen behavior: + +| Marker | Effect | +|--------|--------| +| `# => Union` | Generates result union wrapper (e.g., `FetchProductsResult`) | +| `# Future` | Wraps return type in Promise/async | + +Example: +```graphql +# => Union +type RequestPurchaseResult { + purchase: Purchase + purchases: [Purchase!] +} +``` --- @@ -1047,6 +1226,60 @@ import { openAuthModal } from '../lib/signals'; --- +## Feature Page Hierarchy (Sub-sections) + +When a feature has sub-pages (e.g., Subscription > Upgrade/Downgrade, Alternative Marketplace > Onside), use a **directory structure** instead of hash anchors or flat file naming. + +### Directory Structure + +``` +src/pages/docs/features/ +├── subscription/ +│ ├── index.tsx # Main subscription page +│ └── upgrade-downgrade.tsx # Sub-page +├── alternative-marketplace/ +│ ├── index.tsx # Main overview page +│ └── onside.tsx # Sub-page +├── purchase.tsx # No sub-pages → flat file +└── discount.tsx # No sub-pages → flat file +``` + +### Route Registration (`docs/index.tsx`) + +```tsx +// Imports +import SubscriptionFeature from './features/subscription/index'; +import SubscriptionUpgradeDowngrade from './features/subscription/upgrade-downgrade'; + +// Routes +} /> +} /> +``` + +### Sidebar Navigation + +Use `MenuDropdown` for collapsible parent-child navigation: + +```tsx + +``` + +### Rules + +- **Never use hash anchors (`#section`)** for sub-section navigation in the sidebar — always use separate routes/pages +- Parent page (`index.tsx`) should contain the overview; sub-pages contain detailed content +- Import paths from sub-directories use `../../../../components/` (one level deeper) +- Update all internal `` references when moving files + +--- + ## React Component Organization ### Component Structure @@ -1091,6 +1324,47 @@ src/components/ - Don't keep commented-out code - Remove unused variables and parameters +--- + +## Release Notes Pattern + +### Location + +Release notes are located at `packages/docs/src/pages/docs/updates/releases.tsx`. + +### Adding New Release Notes + +1. Add new entry at the **top** of the `allNotes` array +2. Follow the existing pattern with `id`, `date`, and `element` +3. Use semantic IDs like `gql-1-3-16-apple-1-3-14` + +```tsx +const allNotes: Note[] = [ + // GQL 1.3.16 / Apple 1.3.14 - Jan 26, 2026 + { + id: 'gql-1-3-16-apple-1-3-14', + date: new Date('2026-01-26'), + element: ( +
+ + 📅 openiap-gql v1.3.16 / openiap-apple v1.3.14 - Feature Description + + {/* Content here */} +
+ ), + }, + // ... older notes +]; +``` + +### Required Elements + +- **AnchorLink**: For deep linking to specific release +- **Version info**: Package names and versions in title +- **Date**: In format `new Date('YYYY-MM-DD')` +- **References**: Links to Apple/Google documentation when applicable +- **Issue links**: Reference GitHub issues when fixing bugs + --- @@ -1103,29 +1377,60 @@ src/components/ ## Git Commit Message Format -### With Tag Prefix +### Rules + +- **50 characters max** for the subject line (tag + scope + message combined) +- Everything after the tag MUST be lowercase +- No trailing period +- Use imperative mood ("add" not "added") + +### With Tag and Scope -Everything after the tag MUST be lowercase: +When a commit targets a specific package or library, include the scope: +```text +feat(rn): add offer redemption +fix(expo): resolve purchase crash +fix(flutter): correct discount mapping +feat(kmp): add subscription flow +chore(godot): bump openiap dep +fix(apple): handle StoreKit edge case +fix(google): update billing client ``` -feat: add user authentication system -fix: resolve purchase validation error -docs: update API reference -refactor: simplify product fetching logic -test: add subscription validation tests -chore: update dependencies + +### Without Scope + +For cross-cutting or monorepo-wide changes: + +```text +feat: add RC promote to releases +fix: update repo URLs in package.json +chore: update CI workflow names ``` ### Without Tag Prefix First letter MUST be uppercase: -``` +```text Add user authentication system Fix purchase validation error -Update API reference ``` +### Scope Reference + +| Scope | Package/Library | +|-------|----------------| +| `apple` | `packages/apple` | +| `google` | `packages/google` | +| `gql` | `packages/gql` | +| `docs` | `packages/docs` | +| `rn` | `libraries/react-native-iap` | +| `expo` | `libraries/expo-iap` | +| `flutter` | `libraries/flutter_inapp_purchase` | +| `kmp` | `libraries/kmp-iap` | +| `godot` | `libraries/godot-iap` | + ### Common Tags | Tag | Usage | @@ -1986,6 +2291,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 @@ -2195,11 +2552,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 @@ -2263,10 +2621,18 @@ The plugin: -# react-native-iap API Reference +# react-native-iap API Reference (Legacy) -> Reference documentation for react-native-iap -> Adapt all patterns to match OpenIAP internal conventions. +> **WARNING**: This file contains outdated API names from older versions. +> For the current API spec, refer to the official [OpenIAP documentation](https://openiap.dev/docs/apis). +> +> Key renames from legacy to current: +> +> - `getProducts` → `fetchProducts` +> - `getSubscriptions` → `fetchProducts({ type: 'subs' })` +> - `getPurchaseHistory` → `getAvailablePurchases` +> - `requestSubscription` → `requestPurchase({ type: 'subs' })` +> - `completePurchase` → `finishTransaction` ## Overview @@ -2298,7 +2664,7 @@ function PurchaseScreen() { currentPurchaseError, initConnectionError, finishTransaction, - getProducts, + fetchProducts, getSubscriptions, getAvailablePurchases, getPurchaseHistory, @@ -2339,7 +2705,7 @@ export default withIAPContext(App); import { initConnection, endConnection, - getProducts, + fetchProducts, getSubscriptions, } from 'react-native-iap'; @@ -2347,7 +2713,7 @@ import { const connected = await initConnection(); // Fetch products -const products = await getProducts({ skus: ['com.app.product1'] }); +const products = await fetchProducts({ skus: ['com.app.product1'] }); const subs = await getSubscriptions({ skus: ['com.app.sub_monthly'] }); // Cleanup @@ -2627,13 +2993,13 @@ function Store() { connected, products, subscriptions, - getProducts, + fetchProducts, getSubscriptions, } = useIAP(); useEffect(() => { if (connected) { - getProducts({ skus: productIds }); + fetchProducts({ skus: productIds }); getSubscriptions({ skus: subscriptionIds }); } }, [connected]); @@ -2694,24 +3060,84 @@ 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()` | | `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 + +A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. + +### Important: UUID Format Requirement + +**The `appAccountToken` must be a valid UUID format.** If you provide a non-UUID string (e.g., `"user-123"` or `"my-account-id"`), Apple's StoreKit will silently return `null` for this field in the transaction response. + +#### Valid UUID Examples + +```swift +// Valid UUIDs - these will be returned correctly +"550e8400-e29b-41d4-a716-446655440000" +"6ba7b810-9dad-11d1-80b4-00c04fd430c8" +UUID().uuidString // Generate new UUID +``` + +#### Invalid Examples (Will Return null) + +```swift +// Invalid - NOT UUID format, Apple returns null silently +"user-123" +"my-account-token" +"abc123" +``` + +### Usage in Purchase Options + +```swift +let appAccountToken = UUID() +let result = try await product.purchase(options: [ + .appAccountToken(appAccountToken) +]) +``` + +### Retrieving from Transaction + +```swift +let transaction: Transaction +if let token = transaction.appAccountToken { + // Token will only be present if a valid UUID was provided during purchase + print("App Account Token: \(token)") +} +``` + +### Best Practices + +1. **Generate UUIDs per user**: Create and store a UUID for each user in your system +2. **Use consistent tokens**: Use the same UUID for all purchases from the same user +3. **Server-side mapping**: Map the UUID to your internal user ID on your server +4. **Don't use user IDs directly**: Convert your user IDs to UUIDs rather than using them directly + +### References + +- [Apple Developer Documentation: appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken) +- [GitHub Issue: expo-iap #128](https://github.com/hyochan/expo-iap/issues/128) ## Product @@ -2848,11 +3274,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 ?? "") } ``` @@ -2902,6 +3333,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: @@ -2913,6 +3363,58 @@ if let advancedInfo = product.advancedCommerceInfo { } ``` +## 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 +SK2ConsumableTransactionHistory + +``` + +With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. + ## External Purchase Support (iOS 18.2+) ### Present External Purchase Notice diff --git a/knowledge/external/google-billing-api.md b/knowledge/external/google-billing-api.md index a1bfddd1..8cd16955 100644 --- a/knowledge/external/google-billing-api.md +++ b/knowledge/external/google-billing-api.md @@ -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 diff --git a/knowledge/external/horizon-api.md b/knowledge/external/horizon-api.md index 540d1a68..50897e0a 100644 --- a/knowledge/external/horizon-api.md +++ b/knowledge/external/horizon-api.md @@ -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 diff --git a/knowledge/external/storekit2-api.md b/knowledge/external/storekit2-api.md index c7423d03..a0007c9a 100644 --- a/knowledge/external/storekit2-api.md +++ b/knowledge/external/storekit2-api.md @@ -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()` | | `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 @@ -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 ?? "") } ``` @@ -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: @@ -283,6 +310,58 @@ if let advancedInfo = product.advancedCommerceInfo { } ``` +## 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 +SK2ConsumableTransactionHistory + +``` + +With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. + ## External Purchase Support (iOS 18.2+) ### Present External Purchase Notice diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 81b1f73b..9a4c0338 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -457,7 +457,7 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; -export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android'; +export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -1560,6 +1560,20 @@ export interface Subscription { purchaseError: PurchaseError; /** Fires when a purchase completes successfully or a pending purchase resolves */ purchaseUpdated: Purchase; + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + subscriptionBillingIssue: Purchase; /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -1965,6 +1979,7 @@ export type SubscriptionArgsMap = { promotedProductIOS: never; purchaseError: never; purchaseUpdated: never; + subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 67d7d3f5..1b65274a 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -425,7 +425,12 @@ enum IapEvent { UserChoiceBillingAndroid('user-choice-billing-android'), /// Fired when user selects developer-provided billing option in external payments flow. /// Available on Android with Google Play Billing Library 8.3.0+ - DeveloperProvidedBillingAndroid('developer-provided-billing-android'); + DeveloperProvidedBillingAndroid('developer-provided-billing-android'), + /// Fired when an active subscription enters a billing-issue state that requires user attention. + /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + /// Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + /// Compatibility SDK implements only the Play Billing 7.0 API surface. + SubscriptionBillingIssue('subscription-billing-issue'); const IapEvent(this.value); final String value; @@ -443,6 +448,8 @@ enum IapEvent { return IapEvent.UserChoiceBillingAndroid; case 'developer-provided-billing-android': return IapEvent.DeveloperProvidedBillingAndroid; + case 'subscription-billing-issue': + return IapEvent.SubscriptionBillingIssue; } throw ArgumentError('Unknown IapEvent value: $value'); } @@ -4883,6 +4890,18 @@ abstract class SubscriptionResolver { Future purchaseError(); /// Fires when a purchase completes successfully or a pending purchase resolves Future purchaseUpdated(); + /// Fires when an active subscription enters a billing-issue state that needs user action + /// (payment method failed, card expired, etc.). Cross-platform unification: + /// + /// - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + /// on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + /// - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + /// + /// Listeners should not assume the event will fire on every store. Direct users to the + /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + Future subscriptionBillingIssue(); /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); @@ -5088,6 +5107,7 @@ typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); class SubscriptionHandlers { @@ -5096,6 +5116,7 @@ class SubscriptionHandlers { this.promotedProductIOS, this.purchaseError, this.purchaseUpdated, + this.subscriptionBillingIssue, this.userChoiceBillingAndroid, }); @@ -5103,5 +5124,6 @@ class SubscriptionHandlers { final SubscriptionPromotedProductIOSHandler? promotedProductIOS; final SubscriptionPurchaseErrorHandler? purchaseError; final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; + final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index fed9bd9b..88dbdb17 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -146,6 +146,8 @@ enum IapEvent { USER_CHOICE_BILLING_ANDROID = 3, ## Fired when user selects developer-provided billing option in external payments flow. Available on Android with Google Play Billing Library 8.3.0+ DEVELOPER_PROVIDED_BILLING_ANDROID = 4, + ## Fired when an active subscription enters a billing-issue state that requires user attention. Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing Compatibility SDK implements only the Play Billing 7.0 API surface. + SUBSCRIPTION_BILLING_ISSUE = 5, } ## Unified purchase states from IAPKit verification response. @@ -4286,7 +4288,8 @@ const IAP_EVENT_VALUES = { IapEvent.PURCHASE_ERROR: "purchase-error", IapEvent.PROMOTED_PRODUCT_IOS: "promoted-product-ios", IapEvent.USER_CHOICE_BILLING_ANDROID: "user-choice-billing-android", - IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID: "developer-provided-billing-android" + IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID: "developer-provided-billing-android", + IapEvent.SUBSCRIPTION_BILLING_ISSUE: "subscription-billing-issue" } const IAPKIT_PURCHASE_STATE_VALUES = { @@ -4503,7 +4506,8 @@ const IAP_EVENT_FROM_STRING = { "purchase-error": IapEvent.PURCHASE_ERROR, "promoted-product-ios": IapEvent.PROMOTED_PRODUCT_IOS, "user-choice-billing-android": IapEvent.USER_CHOICE_BILLING_ANDROID, - "developer-provided-billing-android": IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID + "developer-provided-billing-android": IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID, + "subscription-billing-issue": IapEvent.SUBSCRIPTION_BILLING_ISSUE } const IAPKIT_PURCHASE_STATE_FROM_STRING = { diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 90136860..5ad6c0e7 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -503,7 +503,14 @@ public enum class IapEvent(val rawValue: String) { * Fired when user selects developer-provided billing option in external payments flow. * Available on Android with Google Play Billing Library 8.3.0+ */ - DeveloperProvidedBillingAndroid("developer-provided-billing-android"); + DeveloperProvidedBillingAndroid("developer-provided-billing-android"), + /** + * Fired when an active subscription enters a billing-issue state that requires user attention. + * Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + * Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + * Compatibility SDK implements only the Play Billing 7.0 API surface. + */ + SubscriptionBillingIssue("subscription-billing-issue"); companion object { fun fromJson(value: String): IapEvent = when (value) { @@ -522,6 +529,9 @@ public enum class IapEvent(val rawValue: String) { "developer-provided-billing-android" -> IapEvent.DeveloperProvidedBillingAndroid "DEVELOPER_PROVIDED_BILLING_ANDROID" -> IapEvent.DeveloperProvidedBillingAndroid "DeveloperProvidedBillingAndroid" -> IapEvent.DeveloperProvidedBillingAndroid + "subscription-billing-issue" -> IapEvent.SubscriptionBillingIssue + "SUBSCRIPTION_BILLING_ISSUE" -> IapEvent.SubscriptionBillingIssue + "SubscriptionBillingIssue" -> IapEvent.SubscriptionBillingIssue else -> throw IllegalArgumentException("Unknown IapEvent value: $value") } } @@ -4922,6 +4932,20 @@ public interface SubscriptionResolver { * Fires when a purchase completes successfully or a pending purchase resolves */ suspend fun purchaseUpdated(): Purchase + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + suspend fun subscriptionBillingIssue(): Purchase /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -5041,6 +5065,7 @@ public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails public data class SubscriptionHandlers( @@ -5048,5 +5073,6 @@ public data class SubscriptionHandlers( val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null, val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, + val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 81b1f73b..9a4c0338 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -457,7 +457,7 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; -export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android'; +export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -1560,6 +1560,20 @@ export interface Subscription { purchaseError: PurchaseError; /** Fires when a purchase completes successfully or a pending purchase resolves */ purchaseUpdated: Purchase; + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + subscriptionBillingIssue: Purchase; /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -1965,6 +1979,7 @@ export type SubscriptionArgsMap = { promotedProductIOS: never; purchaseError: never; purchaseUpdated: never; + subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 37ce4fcb..33715621 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -14,6 +14,7 @@ actor IapState { private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] private var purchaseErrorListeners: [(id: UUID, listener: PurchaseErrorListener)] = [] private var promotedProductListeners: [(id: UUID, listener: PromotedProductListener)] = [] + private var subscriptionBillingIssueListeners: [(id: UUID, listener: SubscriptionBillingIssueListener)] = [] // MARK: - Init flag func setInitialized(_ value: Bool) { isInitialized = value } @@ -43,6 +44,9 @@ actor IapState { func addPromotedProductListener(_ pair: (UUID, PromotedProductListener)) { promotedProductListeners.append((id: pair.0, listener: pair.1)) } + func addSubscriptionBillingIssueListener(_ pair: (UUID, SubscriptionBillingIssueListener)) { + subscriptionBillingIssueListeners.append((id: pair.0, listener: pair.1)) + } func removeListener(id: UUID, type: IapEvent) { switch type { @@ -52,6 +56,8 @@ actor IapState { purchaseErrorListeners.removeAll { $0.id == id } case .promotedProductIos: promotedProductListeners.removeAll { $0.id == id } + case .subscriptionBillingIssue: + subscriptionBillingIssueListeners.removeAll { $0.id == id } case .userChoiceBillingAndroid: // No-op: User Choice Billing is an Android-only feature os_log(.info, "userChoiceBillingAndroid is not supported on iOS (no-op)") @@ -67,6 +73,7 @@ actor IapState { purchaseUpdatedListeners.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() + subscriptionBillingIssueListeners.removeAll() } func snapshotPurchaseUpdated() -> [PurchaseUpdatedListener] { @@ -78,4 +85,7 @@ actor IapState { func snapshotPromoted() -> [PromotedProductListener] { promotedProductListeners.map { $0.listener } } + func snapshotSubscriptionBillingIssue() -> [SubscriptionBillingIssueListener] { + subscriptionBillingIssueListeners.map { $0.listener } + } } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 72eb9f43..39db135b 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -264,6 +264,11 @@ public enum IapEvent: String, Codable, CaseIterable { /// Fired when user selects developer-provided billing option in external payments flow. /// Available on Android with Google Play Billing Library 8.3.0+ case developerProvidedBillingAndroid = "developer-provided-billing-android" + /// Fired when an active subscription enters a billing-issue state that requires user attention. + /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + /// Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + /// Compatibility SDK implements only the Play Billing 7.0 API surface. + case subscriptionBillingIssue = "subscription-billing-issue" } /// Unified purchase states from IAPKit verification response. @@ -2435,6 +2440,18 @@ public protocol SubscriptionResolver { func purchaseError() async throws -> PurchaseError /// Fires when a purchase completes successfully or a pending purchase resolves func purchaseUpdated() async throws -> Purchase + /// Fires when an active subscription enters a billing-issue state that needs user action + /// (payment method failed, card expired, etc.). Cross-platform unification: + /// + /// - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + /// on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + /// - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + /// + /// Listeners should not assume the event will fire on every store. Direct users to the + /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + func subscriptionBillingIssue() async throws -> Purchase /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails @@ -2652,6 +2669,7 @@ public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async t public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase +public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails public struct SubscriptionHandlers { @@ -2659,6 +2677,7 @@ public struct SubscriptionHandlers { public var promotedProductIOS: SubscriptionPromotedProductIOSHandler? public var purchaseError: SubscriptionPurchaseErrorHandler? public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? + public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? public init( @@ -2666,12 +2685,14 @@ public struct SubscriptionHandlers { promotedProductIOS: SubscriptionPromotedProductIOSHandler? = nil, purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, + subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS self.purchaseError = purchaseError self.purchaseUpdated = purchaseUpdated + self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid } } diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index 5bd5d7eb..7d5270c1 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -773,6 +773,14 @@ import StoreKit return subscription as NSObject } + @objc func addSubscriptionBillingIssueListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject { + let subscription = subscriptionBillingIssueListener { purchase in + let dictionary = OpenIapSerialization.purchase(purchase) + callback(dictionary as NSDictionary) + } + return subscription as NSObject + } + @objc func removeListener(_ subscription: NSObject) { if let sub = subscription as? Subscription { removeListener(sub) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 448364fc..4e9050a6 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -15,6 +15,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { public static let shared = OpenIapModule() private var updateListenerTask: Task? + private var messageListenerTask: Task? private var productManager: ProductManager? private let state = IapState() private var initTask: Task? @@ -28,7 +29,10 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { super.init() } - deinit { updateListenerTask?.cancel() } + deinit { + updateListenerTask?.cancel() + messageListenerTask?.cancel() + } // MARK: - Connection Management @@ -62,6 +66,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { await self.state.setInitialized(true) self.startTransactionListener() + self.startMessageListener() await self.processUnfinishedTransactions() return true } @@ -1322,6 +1327,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return subscription } + public func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + let subscription = Subscription(eventType: .subscriptionBillingIssue) + Task { await state.addSubscriptionBillingIssueListener((subscription.id, listener)) } + return subscription + } + public func removeListener(_ subscription: Subscription) { Task { await state.removeListener(id: subscription.id, type: subscription.eventType) } Task { await MainActor.run { subscription.onRemove?() } } @@ -1503,6 +1514,70 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } + private func emitSubscriptionBillingIssue(_ purchase: Purchase) { + Task { [state] in + let listeners = await state.snapshotSubscriptionBillingIssue() + await MainActor.run { + listeners.forEach { $0(purchase) } + } + } + } + + /// Starts the StoreKit 2 Message listener for subscription-billing-issue events. + /// `StoreKit.Message` is an iOS-only API (unavailable on macOS, tvOS, watchOS, visionOS), + /// and the `.billingIssue` reason requires iOS 18+. On older iOS versions and non-iOS + /// platforms this is a silent no-op. + private func startMessageListener() { + #if os(iOS) && !targetEnvironment(macCatalyst) + if #available(iOS 18.0, *) { + messageListenerTask?.cancel() + messageListenerTask = Task { [weak self] in + guard let self else { return } + for await message in StoreKit.Message.messages { + guard await self.state.isInitialized else { continue } + guard case .billingIssue = message.reason else { continue } + await self.dispatchBillingIssueMessage() + } + } + } + #endif + } + + /// Resolves the affected subscription(s) from current entitlements and emits the event. + /// StoreKit's Message does not carry a transaction reference, so we cross-reference + /// `Transaction.currentEntitlements` for auto-renewable subscriptions whose + /// RenewalState indicates a billing-retry or grace-period condition. + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + private func dispatchBillingIssueMessage() async { + var emitted = false + for await verification in Transaction.currentEntitlements { + guard case .verified(let transaction) = verification, + transaction.productType == .autoRenewable else { continue } + do { + guard let product = try await StoreKit.Product.products(for: [transaction.productID]).first, + let subscription = product.subscription else { continue } + let statusArray = try await subscription.status + guard let latest = statusArray.first else { continue } + switch latest.state { + case .inBillingRetryPeriod, .inGracePeriod: + let purchase = await StoreKitTypesBridge.purchase( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + emitSubscriptionBillingIssue(purchase) + emitted = true + default: + continue + } + } catch { + continue + } + } + if !emitted { + OpenIapLog.debug("🔔 [MessageListener] billingIssue message received but no matching subscription found in retry/grace state") + } + } + private func makePurchaseError( code: ErrorCode, productId: String? = nil, diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index d5746da9..7540fe35 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -29,6 +29,9 @@ public typealias PurchaseErrorListener = @Sendable (PurchaseError) -> Void @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) public typealias PromotedProductListener = @Sendable (String) -> Void +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +public typealias SubscriptionBillingIssueListener = @Sendable (Purchase) -> Void + // MARK: - Protocol // SeeAlso: https://developer.apple.com/documentation/storekit/in-app_purchase @@ -91,6 +94,10 @@ public protocol OpenIapModuleProtocol { func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription + /// Listener for subscription billing-issue events (iOS 18+). + /// Fires when StoreKit delivers a `Message.Reason.billingIssue` for a currently + /// active auto-renewable subscription. On iOS 17 and earlier this is a no-op. + func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription func removeListener(_ subscription: Subscription) func removeAllListeners() } diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index fed7edf4..8fa58b1f 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -1,432 +1,393 @@ # OpenIAP Complete Reference -> OpenIAP: Unified cross-platform in-app purchase SDK +> OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> GitHub: https://github.com/hyodotdev/openiap +> Generated: 2026-04-15T15:48:40.915Z ## Table of Contents - -1. Overview -2. Installation -3. Core API Reference -4. iOS-Specific APIs -5. Android-Specific APIs -6. Types & Data Structures -7. Events -8. Error Handling -9. Platform Usage Examples -10. Naming Conventions - ---- - -# 1. Overview - -OpenIAP is a standardized protocol for in-app purchases across all platforms. -One specification, consistent APIs, platform-specific optimizations. - -**Supported Platforms:** -- React Native (react-native-iap) — npm -- Expo (expo-iap) — npm -- Flutter (flutter_inapp_purchase) — pub.dev -- Godot (godot-iap) — Godot Asset Library -- Kotlin Multiplatform (kmp-iap) — Maven Central -- iOS Native (openiap-apple) — SPM / CocoaPods -- Android Native (openiap-google) — Maven Central - -**Current Versions:** -- react-native-iap: 14.7.20 -- expo-iap: 3.4.13 -- flutter_inapp_purchase: 8.2.10 -- godot-iap: 1.2.9 -- kmp-iap: 1.3.8 -- openiap-apple: 1.3.15 -- openiap-google: 1.3.28 +1. Installation +2. Core APIs (Connection, Products, Purchase, Subscription) +3. Platform-Specific APIs (iOS, Android) +4. Types Reference +5. Error Codes & Handling +6. Implementation Patterns --- -# 2. Installation +## 1. Installation -## React Native +### React Native / Expo ```bash +# expo-iap (Expo projects - recommended) +npx expo install expo-iap + +# react-native-iap (React Native CLI) npm install react-native-iap -# or -yarn add react-native-iap +cd ios && pod install +``` + +### Swift (iOS/macOS) +```swift +// Swift Package Manager +.package(url: "https://github.com/hyodotdev/openiap.git", from: "1.0.0") + +// CocoaPods +pod 'openiap', '~> 1.0.0' +``` + +### Kotlin (Android) +```kotlin +// Gradle (build.gradle.kts) +implementation("io.github.hyochan.openiap:openiap-google:1.0.0") + +// For Meta Horizon OS +implementation("io.github.hyochan.openiap:openiap-google-horizon:1.0.0") +``` + +### Flutter +```yaml +# pubspec.yaml +dependencies: + flutter_inapp_purchase: ^5.0.0 ``` -- iOS: StoreKit 2, requires iOS 15.0+ -- Android: Google Play Billing 8.0+, compileSdkVersion 34+ -- Uses Nitro Modules bridge -## Expo +--- + +# expo-iap API Reference + +> Reference documentation for expo-iap (Expo In-App Purchase module) +> Adapt all patterns to match OpenIAP internal conventions. + +## Overview + +expo-iap is the Expo-compatible version of react-native-iap, providing in-app purchase functionality for both iOS and Android in Expo projects. + +## Installation + ```bash npx expo install expo-iap ``` -- Works with managed and bare workflows -- iOS: Set `"ios": { "deploymentTarget": "15.0" }` in app.json -- Android: Google Play Billing 8.0+ -## Flutter -```bash -flutter pub add flutter_inapp_purchase +## Connection Management + +### initConnection + +Initialize connection to the app store. + +```typescript +import { initConnection } from 'expo-iap'; + +await initConnection(); ``` -- iOS: Requires iOS 15.0+ -- Android: minSdkVersion 21+ -## Godot -- Download from Godot Asset Library or GitHub Release -- Extract to `addons/godot-iap/` -- Enable in Project Settings → Plugins -- Requires Godot 4.x +### endConnection -## Kotlin Multiplatform -```kotlin -// build.gradle.kts -dependencies { - implementation("io.github.hyochan.kmpiap:library:1.3.8") +Close connection to the app store. + +```typescript +import { endConnection } from 'expo-iap'; + +await endConnection(); +``` + +## Product Operations + +### fetchProducts + +Fetch product information from the store. + +```typescript +import { fetchProducts } from 'expo-iap'; + +const products = await fetchProducts(['com.app.product1', 'com.app.sub_monthly']); +``` + +**Returns:** `Promise` + +### Product Type + +```typescript +interface Product { + productId: string; + title: string; + description: string; + price: string; + currency: string; + localizedPrice: string; + type: ProductType; // 'iap' | 'sub' + + // iOS only + subscriptionPeriodNumberIOS?: string; + subscriptionPeriodUnitIOS?: string; + introductoryPrice?: string; + introductoryPricePaymentModeIOS?: string; + introductoryPriceNumberOfPeriodsIOS?: string; + introductoryPriceSubscriptionPeriodIOS?: string; + + // Android only + subscriptionOfferDetailsAndroid?: SubscriptionOffer[]; + oneTimePurchaseOfferDetailsAndroid?: OneTimePurchaseOffer; } ``` -## iOS Native -```swift -// Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "1.3.15") +## Purchase Operations + +### requestPurchase + +Initiate a purchase. + +```typescript +import { requestPurchase } from 'expo-iap'; + +// For consumables/non-consumables +await requestPurchase({ sku: 'com.app.product1' }); + +// For subscriptions (Android) +await requestPurchase({ + sku: 'com.app.sub_monthly', + subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }] +}); +``` + +### finishTransaction + +Complete a transaction after processing. + +```typescript +import { finishTransaction } from 'expo-iap'; + +await finishTransaction({ purchase, isConsumable: true }); ``` -```ruby -# CocoaPods -pod 'openiap', '~> 1.3' + +### getAvailablePurchases + +Get user's existing purchases (restore purchases). + +```typescript +import { getAvailablePurchases } from 'expo-iap'; + +const purchases = await getAvailablePurchases(); ``` -## Android Native -```kotlin -implementation("io.github.hyochan.openiap:openiap-google:1.3.28") +## Purchase Type + +```typescript +interface Purchase { + productId: string; + transactionId?: string; + transactionDate: number; + transactionReceipt: string; + purchaseToken?: string; // Android + + // iOS only + originalTransactionDateIOS?: number; + originalTransactionIdentifierIOS?: string; + + // Android only + purchaseStateAndroid?: number; + isAcknowledgedAndroid?: boolean; + packageNameAndroid?: string; + obfuscatedAccountIdAndroid?: string; + obfuscatedProfileIdAndroid?: string; +} ``` ---- +## iOS-Specific Functions -# 3. Core API Reference +### clearTransactionIOS -## Connection Management +Clear finished transactions from the queue. -### initConnection(config?: InitConnectionConfig): Promise -Initialize connection to store service. Must be called before any IAP operations. +```typescript +import { clearTransactionIOS } from 'expo-iap'; -Parameters: -- config.alternativeBillingAndroid?: 'none' | 'user-choice' | 'alternative-only' - - 'none': Standard Google Play (default) - - 'user-choice': User can select billing method - - 'alternative-only': Alternative billing only +await clearTransactionIOS(); +``` -Returns: boolean indicating success +### getReceiptDataIOS -### endConnection(): Promise -Close store service connection. Call on app close or component unmount. +Get the receipt data for validation. ---- +```typescript +import { getReceiptDataIOS } from 'expo-iap'; -## Product Management +const receipt = await getReceiptDataIOS(); +``` -### fetchProducts(params: ProductRequest): Promise -Retrieve product details from the store. +### syncIOS -Parameters: -- skus: string[] — Product IDs to fetch -- type?: 'inapp' | 'subs' | 'all' (defaults to 'inapp') +Sync transactions with the App Store. -Returns: Product[] with platform-specific fields +```typescript +import { syncIOS } from 'expo-iap'; -### getAvailablePurchases(options?: PurchaseOptions): Promise -Get user's unfinished purchases and active subscriptions. +await syncIOS(); +``` -Parameters (optional): -- alsoPublishToEventListenerIOS?: boolean -- onlyIncludeActiveItemsIOS?: boolean +### presentCodeRedemptionSheetIOS ---- +Show the offer code redemption sheet. -## Purchase Management +```typescript +import { presentCodeRedemptionSheetIOS } from 'expo-iap'; -### requestPurchase(props: RequestPurchaseProps): Promise -Initiate purchase flow. Event-based — results delivered via purchaseUpdatedListener. +await presentCodeRedemptionSheetIOS(); +``` -Parameters: -- request: { apple: { sku: string }, google: { skus: string[] } } -- type: 'inapp' | 'subs' +### showManageSubscriptionsIOS -### finishTransaction(purchase: Purchase, isConsumable?: boolean): Promise -Complete purchase transaction. Must be called after receipt verification. +Open subscription management in App Store. -Parameters: -- purchase: Purchase object from event listener -- isConsumable?: boolean (default false) - - true: Coins, gems (can be repurchased) - - false: Premium unlocks, subscriptions +```typescript +import { showManageSubscriptionsIOS } from 'expo-iap'; -CRITICAL: Android purchases must be acknowledged within 3 days or are auto-refunded. +await showManageSubscriptionsIOS(); +``` -### restorePurchases(): Promise -Restore completed transactions. Fires purchaseUpdatedListener for each restored purchase. +### isEligibleForIntroOfferIOS -### getStorefront(): Promise -Get user's storefront country code (ISO 3166-1 alpha-2, e.g., "US", "JP"). +Check intro offer eligibility. ---- +```typescript +import { isEligibleForIntroOfferIOS } from 'expo-iap'; -## Subscription Management +const eligible = await isEligibleForIntroOfferIOS('com.app.sub_monthly'); +``` -### getActiveSubscriptions(subscriptionIds?: string[]): Promise -Get all active subscriptions with renewal info. Optional filter by IDs. +### beginRefundRequestIOS -### hasActiveSubscriptions(subscriptionIds?: string[]): Promise -Quick check if user has any active subscriptions. +Start a refund request. -### deepLinkToSubscriptions(options: DeepLinkOptions): Promise -Open native subscription management UI. +```typescript +import { beginRefundRequestIOS } from 'expo-iap'; -Parameters: -- skuAndroid: string (required on Android) -- packageNameAndroid: string (required on Android) +const result = await beginRefundRequestIOS('transaction_id'); +``` ---- +## Android-Specific Functions -# 4. iOS-Specific APIs +### acknowledgePurchaseAndroid -## Transaction Management -- `clearTransactionIOS()` — Clear pending transactions from queue -- `getPendingTransactionsIOS()` — Retrieve all pending transactions -- `syncIOS()` — Force StoreKit sync (iOS 15+) +Acknowledge a purchase (required within 3 days). -## Subscription -- `subscriptionStatusIOS(groupID: string)` — Detailed subscription status -- `showManageSubscriptionsIOS()` — In-app subscription management UI -- `isEligibleForIntroOfferIOS(groupID: string)` — Check intro offer eligibility +```typescript +import { acknowledgePurchaseAndroid } from 'expo-iap'; -## Transaction Details -- `currentEntitlementIOS(sku: string)` — Current StoreKit 2 entitlement -- `latestTransactionIOS(sku: string)` — Most recent transaction -- `isTransactionVerifiedIOS(sku: string)` — Verify transaction signature -- `getTransactionJwsIOS(sku: string)` — JWS for server validation -- `getReceiptDataIOS()` — Base64-encoded receipt (legacy) +await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); +``` -## Refunds & Redemption -- `beginRefundRequestIOS(sku: string)` — Initiate refund request -- `presentCodeRedemptionSheetIOS()` — Show promo code redemption -- `getAppTransactionIOS()` — Fetch app transaction (iOS 16+) +### consumePurchaseAndroid -## External Purchase (iOS 17.4+) -- `showExternalPurchaseNoticeIOS(token: string)` — Show external purchase notice -- `getExternalPurchaseCustomLinkIOS(type: TokenType)` — Get external purchase token +Consume a consumable purchase. ---- +```typescript +import { consumePurchaseAndroid } from 'expo-iap'; -# 5. Android-Specific APIs +await consumePurchaseAndroid({ token: purchase.purchaseToken }); +``` -## Purchase Acknowledgment -- `acknowledgePurchaseAndroid(purchaseToken: string)` — Acknowledge non-consumable/subscription -- `consumePurchaseAndroid(purchaseToken: string)` — Consume consumable purchase (auto-acknowledges) +### getPackageNameAndroid -## Alternative Billing Flow (3-step) -1. `checkAlternativeBillingAvailabilityAndroid()` — Check availability -2. `showAlternativeBillingDialogAndroid()` — Show user consent dialog -3. `createAlternativeBillingTokenAndroid()` — Create token for Google Play reporting +Get the app's package name. -## Billing Programs -- `isBillingProgramAvailableAsync(program: BillingProgramAndroid)` — Check program availability -- `createBillingProgramReportingDetailsAsync(program: BillingProgramAndroid)` — Get reporting details +```typescript +import { getPackageNameAndroid } from 'expo-iap'; ---- +const packageName = await getPackageNameAndroid(); +``` -# 6. Types & Data Structures - -## Purchase - -### Common Fields -- id: string — Purchase identifier -- productId: string — Product SKU -- ids?: string[] — Array of SKUs for bundled purchases -- transactionDate: number — Epoch milliseconds -- purchaseToken: string — JWS (iOS) or Play token (Android) -- store: 'apple' | 'google' | 'horizon' -- quantity: number -- purchaseState: PurchaseState -- isAutoRenewing: boolean -- currentPlanId: string - -### PurchaseIOS Additional Fields -- quantityIOS: number -- originalTransactionDateIOS: number -- originalTransactionIdentifierIOS: string -- appAccountToken: string -- expirationDateIOS: number -- webOrderLineItemIdIOS: string -- environmentIOS: 'Sandbox' | 'Production' -- storefrontCountryCodeIOS: string - -### PurchaseAndroid Additional Fields -- orderId: string -- packageName: string -- purchaseTime: number -- purchaseState: 'pending' | 'purchased' | 'unknown' -- autoRenewingAndroid: boolean -- acknowledgementState: 'unacknowledged' | 'acknowledged' -- obfuscatedAccountIdAndroid: string -- obfuscatedProfileIdAndroid: string +## Cross-Platform Functions -## Product +### getActiveSubscriptions -### Common Fields -- id: string — SKU -- title: string — Display name -- description: string — Full description -- localizedPrice: string — Formatted price (e.g., "$9.99") -- priceAmount: number — Numeric price value -- currency: string — ISO 4217 currency code -- type: ProductType ('inapp' | 'subs') -- platform: 'ios' | 'android' - -### Subscription Product (extends Product) -- subscriptionPeriod: string — Duration (e.g., "P1M" = 1 month) -- introductoryPrice?: string -- introductoryPriceAmount?: number -- introductoryPricePeriod?: string -- introductoryPriceNumberOfPeriods?: number -- introductoryPaymentMode?: PaymentMode -- freeTrialPeriod?: string - -### ActiveSubscription -- productId: string -- isActive: boolean -- expirationDateIOS: number -- autoRenewingAndroid: boolean -- transactionId: string -- purchaseToken: string -- transactionDate: number -- basePlanIdAndroid: string -- currentPlanId: string -- renewalInfoIOS: { willAutoRenew, pendingUpgradeProductId, renewalDate, expirationReason } - -## Enums - -### ProductType -- InApp — One-time purchase -- Subs — Subscription - -### ProductQueryType -- InApp — Fetch in-app products only -- Subs — Fetch subscriptions only -- All — Fetch all products - -### PurchaseState -- Pending -- Purchased -- Unknown - -### IapStore -- unknown, apple, google, horizon - -### PaymentMode -- FreeTrial, PayAsYouGo, PayUpFront - -### SubscriptionPeriodUnit -- Day, Week, Month, Year - -### BillingProgramAndroid -- UserChoice, ExternalContent, ExternalOffers, ExternalPayments +Get active subscriptions. ---- +```typescript +import { getActiveSubscriptions } from 'expo-iap'; -# 7. Events +const subscriptions = await getActiveSubscriptions(['com.app.sub_monthly']); +``` -## Event Types +### hasActiveSubscriptions -### purchaseUpdatedListener(callback: (purchase: Purchase) => void): Subscription -Fires when purchase succeeds or pending purchase completes. -Handle: Validate receipt → Grant entitlement → finishTransaction() +Check if user has active subscriptions. -### purchaseErrorListener(callback: (error: PurchaseError) => void): Subscription -Fires on purchase failure or user cancellation. -Handle based on error code. +```typescript +import { hasActiveSubscriptions } from 'expo-iap'; -### promotedProductIOS(callback: (productId: string) => void): Subscription -iOS promoted product action (iOS 11+). Triggered from App Store promotions. +const hasActive = await hasActiveSubscriptions(['com.app.sub_monthly']); +``` -### userChoiceBillingAndroid(callback: (details) => void): Subscription -User selected alternative billing method (Android 7.0+). +### deepLinkToSubscriptions -### developerProvidedBillingAndroid(callback: (details) => void): Subscription -User selected developer-provided billing (Android 8.3.0+). +Open subscription management on both platforms. ---- +```typescript +import { deepLinkToSubscriptions } from 'expo-iap'; -# 8. Error Handling - -## Error Structure -``` -PurchaseError { - code: ErrorCode // Enum value (kebab-case string) - message: string // Human-readable message - productId?: string // Related product - responseCode?: number // Platform-specific code - debugMessage?: string // Additional debug info -} -``` - -## Error Codes (ErrorCode enum) - -### User Action Errors -- user-cancelled — User cancelled purchase flow -- user-error — User account error -- deferred-payment — Payment deferred (family approval) -- interrupted — Purchase flow interrupted - -### Product Errors -- item-unavailable — Product not available in store -- sku-not-found — SKU not found -- sku-offer-mismatch — SKU offer ID mismatch -- query-product — Failed to query product details -- already-owned — Item already owned -- item-not-owned — Item not owned - -### Network & Service Errors -- network-error — No network connection -- service-error — Store service error -- remote-error — Remote server error -- init-connection — Failed to initialize connection -- service-disconnected — Store service disconnected -- connection-closed — Connection closed -- iap-not-available — IAP service not available -- billing-unavailable — Billing service unavailable -- feature-not-supported — Feature not supported -- sync-error — Synchronization error - -### Validation Errors -- purchase-verification-failed — Purchase verification failed -- purchase-verification-finished — Verification already completed -- purchase-verification-finish-failed — Failed to finish verification -- transaction-validation-failed — Transaction validation failed -- empty-sku-list — Empty SKU list provided -- duplicate-purchase — Duplicate purchase update detected (use restorePurchases to recover) - -### General -- not-prepared — Store not initialized -- developer-error — Invalid API usage -- unknown — Unexpected error - -## Retry Strategy (Built-in) -- NetworkError: Exponential backoff (2^n seconds) -- ServiceError: Linear backoff (n * 5 seconds) -- RemoteError: Fixed delay (10 seconds) -- ConnectionClosed: Reinitialize and retry -- UserCancelled, AlreadyOwned: No retry - -## Helper Functions -- `isUserCancelledError(error)` — Check if user cancellation -- `getUserFriendlyErrorMessage(error)` — Get display-safe message +await deepLinkToSubscriptions({ sku: 'com.app.sub_monthly' }); +``` ---- +### getStorefront + +Get storefront information. + +```typescript +import { getStorefront } from 'expo-iap'; + +const storefront = await getStorefront(); +// { countryCode: 'US', ... } +``` + +## Event Listeners + +### purchaseUpdatedListener + +Listen for purchase updates. + +```typescript +import { purchaseUpdatedListener } from 'expo-iap'; + +const subscription = purchaseUpdatedListener((purchase) => { + console.log('Purchase updated:', purchase); + // Process and finish transaction +}); + +// Cleanup +subscription.remove(); +``` + +### purchaseErrorListener + +Listen for purchase errors. + +```typescript +import { purchaseErrorListener } from 'expo-iap'; + +const subscription = purchaseErrorListener((error) => { + console.error('Purchase error:', error); +}); + +// Cleanup +subscription.remove(); +``` + +## Error Codes -# 9. Platform Usage Examples +| Code | Description | +|------|-------------| +| `E_UNKNOWN` | Unknown error | +| `E_USER_CANCELLED` | User cancelled | +| `E_ITEM_UNAVAILABLE` | Item not available | +| `E_NETWORK_ERROR` | Network error | +| `E_SERVICE_ERROR` | Store service error | +| `E_ALREADY_OWNED` | Item already owned | +| `E_NOT_PREPARED` | Not initialized | +| `E_NOT_ENDED` | Connection not ended | +| `E_DEVELOPER_ERROR` | Developer error | -## TypeScript (React Native) +## Usage Pattern ```typescript import { @@ -437,252 +398,1570 @@ import { finishTransaction, purchaseUpdatedListener, purchaseErrorListener, - ErrorCode, - isUserCancelledError, -} from 'react-native-iap'; +} from 'expo-iap'; -// Initialize +// Setup await initConnection(); -// Fetch products -const products = await fetchProducts({ skus: ['premium', 'coins_100'] }); - -// Listen for events -const purchaseSub = purchaseUpdatedListener(async (purchase) => { - // Validate on server, then: - await finishTransaction(purchase, purchase.productId === 'coins_100'); +const purchaseListener = purchaseUpdatedListener(async (purchase) => { + // Verify purchase server-side + // Then finish transaction + await finishTransaction({ purchase, isConsumable: false }); }); -const errorSub = purchaseErrorListener((error) => { - if (isUserCancelledError(error)) return; - console.error(error.code, error.message); +const errorListener = purchaseErrorListener((error) => { + console.error(error); }); -// Purchase -await requestPurchase({ - request: { apple: { sku: 'premium' }, google: { skus: ['premium'] } }, - type: 'inapp', -}); +// Fetch products +const products = await fetchProducts(['com.app.premium']); + +// Make purchase +await requestPurchase({ sku: 'com.app.premium' }); // Cleanup -purchaseSub.remove(); -errorSub.remove(); +purchaseListener.remove(); +errorListener.remove(); await endConnection(); ``` -## TypeScript (React Native — useIAP Hook) -```typescript -import { useIAP, ErrorCode } from 'react-native-iap'; +--- -function PurchaseScreen() { - const { - products, - fetchProducts, - requestPurchase, - activeSubscriptions, - getActiveSubscriptions, - } = useIAP({ - onPurchaseSuccess: (purchase) => { - // Validate and finish - }, - onPurchaseError: (error) => { - if (error.code === ErrorCode.UserCancelled) return; - Alert.alert('Error', error.message); - }, - }); +# Google Play Billing Library API Reference - useEffect(() => { - fetchProducts({ skus: ['premium'] }); - }, []); -} +> Reference documentation for Google Play Billing Library 8.x +> Adapt all patterns to match OpenIAP internal conventions. + +## Overview + +Google Play Billing Library enables in-app purchases and subscriptions on Android devices. + +## Version History + +| Version | Release Date | Key Features | +|---------|--------------|--------------| +| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes | +| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode | +| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | +| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | +| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | + +**Current Version**: 8.3.0 (as of January 2026) + +## Core Classes + +### BillingClient + +The main interface for communicating with Google Play Billing. + +```kotlin +val billingClient = BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListener) + .enablePendingPurchases() + // New in 8.0: Auto-reconnect on service disconnect + .enableAutoServiceReconnection() + .build() ``` -## TypeScript (Expo) +### Auto Service Reconnection (8.0+) -```typescript -import { useIAP, ErrorCode } from 'expo-iap'; +```kotlin +// Enables automatic reconnection when service disconnects +BillingClient.newBuilder(context) + .enableAutoServiceReconnection() + .build() +``` -// Same API as react-native-iap useIAP hook -const { products, fetchProducts, requestPurchase } = useIAP({ - onPurchaseSuccess: (purchase) => { /* ... */ }, - onPurchaseError: (error) => { /* ... */ }, -}); +When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. + +> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. + +### Connection Management + +```kotlin +billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + // Ready to query purchases + } + } + + override fun onBillingServiceDisconnected() { + // Reconnect on next request + } +}) ``` -## Dart (Flutter) +## Product Details -```dart -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +### QueryProductDetailsParams -final iap = FlutterInappPurchase.instance; +```kotlin +val productList = listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("product_id") + .setProductType(BillingClient.ProductType.SUBS) // or INAPP + .build() +) + +val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + +billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + // Handle product details +} +``` -// Initialize -await iap.initConnection(); +### ProductDetails Properties -// Fetch products -final products = await iap.fetchProducts( - skus: ['premium', 'coins_100'], - type: ProductQueryType.InApp, -); +| Property | Type | Description | +|----------|------|-------------| +| `productId` | String | Unique product identifier | +| `productType` | String | "subs" or "inapp" | +| `title` | String | Localized product title | +| `name` | String | Product name | +| `description` | String | Localized description | +| `oneTimePurchaseOfferDetails` | Object | For INAPP products | +| `subscriptionOfferDetails` | List | For subscription products | -// Fetch subscriptions -final subs = await iap.fetchProducts( - skus: ['monthly_pro'], - type: ProductQueryType.Subs, -); +### Subscription Offer Details -// Listen for events -iap.purchaseUpdatedStream.listen((purchase) async { - await iap.finishTransaction(purchase, isConsumable: true); -}); +```kotlin +data class SubscriptionOfferDetails( + val basePlanId: String, + val offerId: String?, + val offerToken: String, + val pricingPhases: PricingPhases, + val offerTags: List +) +``` -iap.purchaseErrorStream.listen((error) { - print('${error.code}: ${error.message}'); -}); +### Pricing Phases + +```kotlin +data class PricingPhase( + val formattedPrice: String, + val priceAmountMicros: Long, + val priceCurrencyCode: String, + val billingPeriod: String, // ISO 8601 (P1W, P1M, P1Y) + val billingCycleCount: Int, + val recurrenceMode: Int // FINITE or INFINITE +) +``` -// Purchase -await iap.requestPurchase(sku: 'premium'); +## Purchase Flow -// Cleanup -await iap.endConnection(); +### Launch Purchase Flow + +```kotlin +val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) // For subscriptions + .build() + +val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + +val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams) ``` -## GDScript (Godot) +### PurchasesUpdatedListener + +```kotlin +val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + purchases?.forEach { purchase -> + handlePurchase(purchase) + } + } + BillingClient.BillingResponseCode.USER_CANCELED -> { + // User cancelled + } + else -> { + // Handle error + } + } +} +``` -```gdscript -const Types = preload("res://addons/godot-iap/types.gd") +## Purchase Verification & Acknowledgement -func _ready(): - GodotIapPlugin.purchase_updated.connect(_on_purchase_updated) - GodotIapPlugin.purchase_error.connect(_on_purchase_error) - GodotIapPlugin.init_connection() +### Verify Purchase -func _load_products(): - var request = Types.ProductRequest.new() - request.skus = ["premium", "coins_100"] - request.type = Types.ProductQueryType.InApp - var products = GodotIapPlugin.fetch_products(request) +```kotlin +val purchase: Purchase -func _purchase(sku: String): - var props = Types.RequestPurchaseProps.new() - props.sku = sku - GodotIapPlugin.request_purchase(props) +// Check purchase state +if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + // Verify signature server-side + // Then acknowledge or consume +} +``` -func _on_purchase_updated(purchase): - GodotIapPlugin.finish_transaction(purchase, true) +### Acknowledge Purchase (Subscriptions/Non-consumables) -func _on_purchase_error(error): - print("Error: ", error.code, " - ", error.message) +```kotlin +if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> + // Handle result + } +} ``` -## Kotlin (KMP) +### Consume Purchase (Consumables) ```kotlin -import io.github.hyochan.kmpiap.KmpIAP +val consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() -val kmpIAP = KmpIAP() +billingClient.consumeAsync(consumeParams) { billingResult, purchaseToken -> + // Handle result +} +``` -// Initialize -kmpIAP.initConnection() +## Query Existing Purchases -// Fetch products -val products = kmpIAP.fetchProducts(skus = listOf("premium", "coins_100")) +```kotlin +// Query subscriptions +billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() +) { billingResult, purchasesList -> + // Handle existing subscriptions +} -// Listen for events -kmpIAP.purchaseUpdatedListener.collect { purchase -> - // Validate receipt on server - kmpIAP.finishTransaction(purchase = purchase, isConsumable = true) +// Query in-app products +billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.INAPP) + .build() +) { billingResult, purchasesList -> + // Handle existing purchases } +``` + +## Purchase Properties + +| Property | Type | Description | +|----------|------|-------------| +| `orderId` | String | Unique order identifier | +| `purchaseToken` | String | Token for verification | +| `purchaseState` | Int | PENDING, PURCHASED, UNSPECIFIED | +| `purchaseTime` | Long | Timestamp in milliseconds | +| `products` | List | Product IDs in purchase | +| `isAcknowledged` | Boolean | Whether acknowledged | +| `isAutoRenewing` | Boolean | Auto-renewal status | +| `quantity` | Int | Quantity purchased | + +## Response Codes + +| Code | Constant | Description | +|------|----------|-------------| +| 0 | OK | Success | +| 1 | USER_CANCELED | User cancelled | +| 2 | SERVICE_UNAVAILABLE | Network error | +| 3 | BILLING_UNAVAILABLE | Billing not available | +| 4 | ITEM_UNAVAILABLE | Item not available | +| 5 | DEVELOPER_ERROR | Invalid arguments | +| 6 | ERROR | Fatal error | +| 7 | ITEM_ALREADY_OWNED | Already owned | +| 8 | ITEM_NOT_OWNED | Not owned | + +## Feature Support -kmpIAP.purchaseErrorListener.collect { error -> - println("Error: ${error.code} - ${error.message}") +```kotlin +// Check if feature is supported +val result = billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS) +if (result.responseCode == BillingClient.BillingResponseCode.OK) { + // Subscriptions are supported } +``` -// Purchase -kmpIAP.requestPurchase(sku = "premium") +### Feature Types -// Cleanup -kmpIAP.endConnection() +- `SUBSCRIPTIONS` - Subscription support +- `SUBSCRIPTIONS_UPDATE` - Subscription upgrades/downgrades +- `PRICE_CHANGE_CONFIRMATION` - Price change confirmation +- `PRODUCT_DETAILS` - Product details API + +## Product-Level Status Codes (8.0+) + +In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why. + +```kotlin +billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + productDetailsList.forEach { productDetails -> + when (productDetails.productStatus) { + ProductDetails.ProductStatus.OK -> { + // Product fetched successfully + } + ProductDetails.ProductStatus.NOT_FOUND -> { + // SKU doesn't exist in Play Console + } + ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> { + // User not eligible for any offers + } + } + } +} ``` -## Swift (iOS Native) +| Status | Description | +|--------|-------------| +| `OK` | Product fetched successfully | +| `NOT_FOUND` | SKU doesn't exist in Play Console | +| `NO_OFFERS_AVAILABLE` | User not eligible for any offers | -```swift -import OpenIAP +## Suspended Subscriptions (8.1+) -let store = OpenIapStore() +```kotlin +val purchase: Purchase -// Initialize -try await store.initConnection() +// Check if subscription is suspended due to billing issue +if (purchase.isSuspended) { + // User's payment method failed + // Do NOT grant entitlements + // Direct user to subscription center to fix payment +} +``` -// Fetch products -let products = try await store.fetchProducts(skus: ["premium"]) +### Query Suspended Subscriptions (8.1+) + +```kotlin +// Include suspended subscriptions in query results +val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .setIncludeSuspended(true) // New in 8.1 + .build() + +billingClient.queryPurchasesAsync(params) { billingResult, purchases -> + purchases.forEach { purchase -> + if (purchase.isSuspended) { + // Handle suspended subscription + } + } +} +``` + +> **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status. + +## Sub-Response Codes (8.0+) -// Purchase -let result = try await store.requestPurchase(sku: "premium") +`BillingResult` includes a sub-response code for more granular error information: -// Finish -try await store.finishTransaction(purchase: result) +```kotlin +val result = billingClient.launchBillingFlow(activity, params) +when (result.subResponseCode) { + BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { + // User's payment method has insufficient funds + } + BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { + // User doesn't meet offer eligibility requirements + } +} ``` -## Kotlin (Android Native) +| Sub-Response Code | Description | +|-------------------|-------------| +| `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds | +| `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | +| `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | + +## Subscription Product Replacement (8.1+) + +Product-level replacement parameters for subscription upgrades/downgrades: ```kotlin -import dev.hyo.openiap.OpenIapStore +val replacementParams = SubscriptionProductReplacementParams.newBuilder() + .setOldProductId("old_subscription_id") + .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION) + .build() + +val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(newProductDetails) + .setOfferToken(offerToken) + .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1 + .build() +``` -val store = OpenIapStore(context) +### Replacement Modes -// Initialize -store.initConnection() +| Mode | Description | +|------|-------------| +| `WITH_TIME_PRORATION` | Immediate, expiration time prorated | +| `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle | +| `CHARGE_FULL_PRICE` | Immediate, full price charged | +| `WITHOUT_PRORATION` | Takes effect on old plan expiration | +| `DEFERRED` | Deferred, no charge | +| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) | -// Fetch products -val products = store.fetchProducts(skus = listOf("premium")) +## 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. -// Purchase -store.requestPurchase(activity = this, sku = "premium") +### Enable Developer Billing Option -// Listen -store.purchaseUpdatedFlow.collect { purchase -> - store.finishTransaction(purchase) +```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() -# 10. Naming Conventions +billingClient.launchBillingFlow(activity, params) +``` -## Function Naming -- Cross-platform: No suffix (`initConnection`, `fetchProducts`) -- iOS-only: `IOS` suffix (`getStorefrontIOS`, `clearTransactionIOS`) -- Android-only: `Android` suffix (`acknowledgePurchaseAndroid`) +### Key Types (8.3+) -## Type Naming -- iOS types: `IOS` suffix (`PurchaseIOS`, `ProductIOS`) -- Android types: `Android` suffix (`PurchaseAndroid`, `ProductAndroid`) -- IAP prefix: Use `Iap` not `IAP` when followed by other words (`IapPurchase`) +| 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 | -## Field Naming -- ID fields: Always `Id` not `ID` (`productId`, `transactionId`) -- Platform fields: Suffix with `IOS` or `Android` (`quantityIOS`, `orderIdAndroid`) +> **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. -## Error Codes -- Always kebab-case: `user-cancelled`, `network-error` -- Use `ErrorCode` enum, never string literals +## Best Practices + +1. **Always acknowledge purchases** within 3 days or they will be refunded +2. **Verify purchases server-side** using Google Play Developer API +3. **Handle pending purchases** for payment methods that require additional steps +4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) +5. **Check product status codes** (8.0+) to understand why products weren't fetched +6. **Check isSuspended** (8.1+) before granting entitlements +7. **Cache product details** to avoid repeated queries -## Commit Messages -- With tag: `feat: add new feature` (lowercase after tag) -- Without tag: `Add new feature` (uppercase first letter) -- Types: feat, fix, docs, style, refactor, perf, test, chore --- -## Links +# Meta Horizon IAP API Reference -- Documentation: https://openiap.dev -- GitHub: https://github.com/hyodotdev/openiap -- APIs: https://openiap.dev/docs/apis -- Types: https://openiap.dev/docs/types -- Events: https://openiap.dev/docs/events -- Errors: https://openiap.dev/docs/errors -- Discussions: https://github.com/hyodotdev/openiap/discussions +> External reference for Meta Horizon Store in-app purchase APIs. +> Source: [Meta Horizon Documentation](https://developers.meta.com/horizon/documentation/) + +## Overview + +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 + +## Version Compatibility Matrix + +| Library | Version | Compatible With | +|---------|---------|-----------------| +| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | +| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | +| react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | +| expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | + +**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. + +When writing shared code for both Play and Horizon flavors: +- Use only APIs that exist in **both** Billing 7.0 and 8.x +- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` +- OpenIAP handles this automatically with flavor-specific implementations + +### APIs Available in Both (Safe to use in shared code) + +- `BillingClient.Builder`, `BillingClient.newBuilder()` +- `queryProductDetailsAsync()` - Core product query +- `launchBillingFlow()` - Purchase flow +- `acknowledgePurchase()` - Acknowledge (no-op in Horizon) +- `consumeAsync()` - Consume purchase +- `queryPurchasesAsync()` - Query purchases + +### APIs Only in Billing 8.x (DO NOT use in shared code) + +- `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+) +- `isSuspended` on Purchase (8.1+) +- `includeSuspended` parameter in `QueryPurchasesParams` (8.1+) +- `SubscriptionProductReplacementParams` (8.1+) +- Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) +- External Payments / Developer Billing Options (8.3+) + +## Billing Compatibility SDK + +For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. + +### Compatibility + +- Compatible with **Google Play Billing Library 7.0** API +- Supports: consumable, durable, and subscription IAP +- Kotlin 2+ required + +### Migration Steps + +Replace imports from: +```kotlin +import com.android.billingclient.api.* +``` + +To: +```kotlin +import com.meta.horizon.billingclient.api.* +``` + +### Key Differences from Google Play Billing + +| Feature | Google Play | Horizon | +|---------|-------------|---------| +| `acknowledgePurchase()` | Required within 3 days | No-op (not required) | +| Non-acknowledgement | Auto-refund after 3 days | No auto-refund | +| `enablePendingPurchases()` | Enables pending purchases | No-op (for compatibility) | +| `onBillingServiceDisconnected()` | Called on disconnect | Never invoked | + +### Important Notes + +- Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs +- Only call `consumeAsync()` on consumable items +- `acknowledgePurchase()` is no-op - no acknowledgement requirements + +## Server-to-Server (S2S) APIs + +### Authentication + +Access token format: `OC|App_ID|App_Secret` + +### Verify Entitlement + +Verify that a user owns an item (app or add-on). + +**Endpoint:** + +```http +POST https://graph.oculus.com/$APP_ID/verify_entitlement +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `access_token` | string | `OC\|App_ID\|App_Secret` format | +| `user_id` | string | The user ID to verify | +| `sku` | string | (Optional) SKU for add-on verification | + +**Example - Verify App Ownership:** +```bash +curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ + -d "user_id=$USER_ID" \ + https://graph.oculus.com/$APP_ID/verify_entitlement +``` + +**Example - Verify Add-on/IAP:** +```bash +curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ + -d "user_id=$USER_ID" \ + -d "sku=$SKU" \ + https://graph.oculus.com/$APP_ID/verify_entitlement +``` + +**Response:** +```json +{ + "success": true +} +``` + +### Refund IAP Entitlement + +Refund a DURABLE or CONSUMABLE entitlement (not yet consumed). + +**Endpoint:** + +```http +POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `access_token` | string | `OC\|App_ID\|App_Secret` format | +| `user_id` | string | The user ID | +| `sku` | string | SKU of item to refund | + +**Note:** Can only refund items not yet consumed via `consumeAsync()`. + +## Platform SDK IAP (Native) + +### Product Types + +| Type | Description | +|------|-------------| +| `CONSUMABLE` | Can be purchased multiple times (e.g., coins) | +| `DURABLE` | One-time purchase, permanent ownership | +| `SUBSCRIPTION` | Recurring billing | + +### Key APIs + +#### Get Products + +Retrieve product information and pricing. + +#### Launch Purchase Flow + +Initiate purchase for an item. + +#### Query Purchase History + +Get user's purchase history. + +#### Consume Purchase + +Mark consumable item as used (required for re-purchase). + +## OpenIAP Type Mapping + +| OpenIAP Type | Description | +|--------------|-------------| +| `IapStore.Horizon` | Store identifier for Horizon | +| `VerifyPurchaseHorizonOptions` | Horizon verification parameters | +| `VerifyPurchaseResultHorizon` | Horizon verification result | + +### VerifyPurchaseHorizonOptions + +```typescript +interface VerifyPurchaseHorizonOptions { + userId: string; // Horizon user ID + sku: string; // Product SKU + 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 +interface VerifyPurchaseResultHorizon { + success: boolean; // Verification result +} +``` + +## Entitlement Check + +Apps must perform entitlement check within 10 seconds of launch for VRC.Quest.Security.1 compliance. + +## React Native / Expo Support + +Meta Quest supports React Native and Expo applications. + +### Requirements + +| Library | Minimum Version | Notes | +|---------|-----------------|-------| +| react-native-iap | v14+ | Billing 7.0+, Kotlin 2.0+, RN 0.79+ | +| expo-iap | latest | Uses expo-horizon-core plugin | +| React Native | 0.79+ | Required for Nitro modules | +| Kotlin | 2.0+ | Required for both billing SDKs | + +### Expo Integration + +Use `expo-horizon-core` plugin for Quest support: + +```bash +npx expo install expo-horizon-core +``` + +The plugin: +- Removes unsupported dependencies/permissions +- Configures Android product flavors +- Specifies Meta Horizon App ID +- Provides Quest-specific JS utilities + +### Known Limitations on Quest + +- No GPS sensor (limited location accuracy) +- No geocoding support +- No device heading +- No background location +- Some Expo libraries need forks (expo-location, expo-notifications) + +## Documentation Links + +- [Platform SDK IAP Package](https://developers.meta.com/horizon/documentation/android-apps/ps-platform-sdk-iap) +- [S2S APIs](https://developers.meta.com/horizon/documentation/unity/ps-iap-s2s/) +- [Billing Compatibility SDK](https://developers.meta.com/horizon/documentation/spatial-sdk/horizon-billing-compatibility-sdk/) +- [Entitlement Check](https://developers.meta.com/horizon/documentation/android-apps/ps-entitlement-check/) +- [React Native on Quest](https://developers.meta.com/horizon/documentation/android-apps/react-native-apps) +- [Expo Quest Setup](https://blog.swmansion.com/how-to-add-meta-quest-support-to-your-expo-app-68c52778b1fe) +- [Subscriptions](https://developers.meta.com/horizon/resources/subscriptions/) +- [Setting up Add-ons](https://developers.meta.com/horizon/resources/add-ons-setup/) + + +--- + +# react-native-iap API Reference (Legacy) + +> **WARNING**: This file contains outdated API names from older versions. +> For the current API spec, refer to the official [OpenIAP documentation](https://openiap.dev/docs/apis). +> +> Key renames from legacy to current: +> +> - `getProducts` → `fetchProducts` +> - `getSubscriptions` → `fetchProducts({ type: 'subs' })` +> - `getPurchaseHistory` → `getAvailablePurchases` +> - `requestSubscription` → `requestPurchase({ type: 'subs' })` +> - `completePurchase` → `finishTransaction` + +## Overview + +react-native-iap is a React Native library for in-app purchases on iOS and Android. expo-iap is built on top of this library. + +## Installation + +```bash +npm install react-native-iap +# or +yarn add react-native-iap +``` + +## Hook-Based API (Recommended) + +### useIAP Hook + +```typescript +import { useIAP } from 'react-native-iap'; + +function PurchaseScreen() { + const { + connected, + products, + subscriptions, + purchaseHistory, + availablePurchases, + currentPurchase, + currentPurchaseError, + initConnectionError, + finishTransaction, + fetchProducts, + getSubscriptions, + getAvailablePurchases, + getPurchaseHistory, + requestPurchase, + requestSubscription, + } = useIAP(); + + useEffect(() => { + if (currentPurchase) { + // Process purchase + finishTransaction({ purchase: currentPurchase }); + } + }, [currentPurchase]); + + return (/* ... */); +} +``` + +### withIAPContext HOC + +Wrap your app with IAP context provider. + +```typescript +import { withIAPContext } from 'react-native-iap'; + +function App() { + return ; +} + +export default withIAPContext(App); +``` + +## Imperative API + +### Connection Management + +```typescript +import { + initConnection, + endConnection, + fetchProducts, + getSubscriptions, +} from 'react-native-iap'; + +// Initialize +const connected = await initConnection(); + +// Fetch products +const products = await fetchProducts({ skus: ['com.app.product1'] }); +const subs = await getSubscriptions({ skus: ['com.app.sub_monthly'] }); + +// Cleanup +await endConnection(); +``` + +### Product Types + +```typescript +interface Product { + productId: string; + price: string; + currency: string; + localizedPrice: string; + title: string; + description: string; + type: 'inapp' | 'subs'; + + // iOS + introductoryPrice?: string; + introductoryPriceAsAmountIOS?: string; + introductoryPricePaymentModeIOS?: string; + introductoryPriceNumberOfPeriodsIOS?: string; + introductoryPriceSubscriptionPeriodIOS?: string; + subscriptionPeriodNumberIOS?: string; + subscriptionPeriodUnitIOS?: string; + discounts?: Discount[]; + + // Android + subscriptionOfferDetails?: SubscriptionOffer[]; + oneTimePurchaseOfferDetails?: OneTimePurchaseOffer; +} + +interface SubscriptionOffer { + basePlanId: string; + offerId?: string; + offerToken: string; + offerTags: string[]; + pricingPhases: PricingPhase[]; +} + +interface PricingPhase { + formattedPrice: string; + priceCurrencyCode: string; + priceAmountMicros: string; + billingPeriod: string; + billingCycleCount: number; + recurrenceMode: number; +} +``` + +### Purchase Operations + +```typescript +import { + requestPurchase, + requestSubscription, + finishTransaction, + getAvailablePurchases, + getPurchaseHistory, +} from 'react-native-iap'; + +// Purchase consumable/non-consumable +await requestPurchase({ sku: 'com.app.product1' }); + +// Purchase subscription (Android with offer token) +await requestSubscription({ + sku: 'com.app.sub_monthly', + subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }], +}); + +// Finish transaction +await finishTransaction({ purchase, isConsumable: true }); + +// Get available purchases (restore) +const available = await getAvailablePurchases(); + +// Get purchase history +const history = await getPurchaseHistory(); +``` + +### Purchase Type + +```typescript +interface Purchase { + productId: string; + transactionId?: string; + transactionDate: number; + transactionReceipt: string; + purchaseToken?: string; + quantityIOS?: number; + originalTransactionDateIOS?: number; + originalTransactionIdentifierIOS?: string; + verificationResultIOS?: string; + appAccountToken?: string; + + // Android + purchaseStateAndroid?: PurchaseStateAndroid; + isAcknowledgedAndroid?: boolean; + packageNameAndroid?: string; + developerPayloadAndroid?: string; + obfuscatedAccountIdAndroid?: string; + obfuscatedProfileIdAndroid?: string; + autoRenewingAndroid?: boolean; +} +``` + +## Event Listeners + +```typescript +import { + purchaseUpdatedListener, + purchaseErrorListener, +} from 'react-native-iap'; + +// Purchase updates +const purchaseUpdateSubscription = purchaseUpdatedListener( + async (purchase: Purchase) => { + const receipt = purchase.transactionReceipt; + if (receipt) { + // Verify with server + await finishTransaction({ purchase }); + } + } +); + +// Purchase errors +const purchaseErrorSubscription = purchaseErrorListener( + (error: PurchaseError) => { + console.warn('purchaseErrorListener', error); + } +); + +// Cleanup +purchaseUpdateSubscription.remove(); +purchaseErrorSubscription.remove(); +``` + +## iOS-Specific Functions + +```typescript +import { + clearTransactionIOS, + clearProductsIOS, + getReceiptIOS, + getPendingPurchasesIOS, + getPromotedProductIOS, + buyPromotedProductIOS, + presentCodeRedemptionSheetIOS, + validateReceiptIos, +} from 'react-native-iap'; + +// Clear finished transactions +await clearTransactionIOS(); + +// Clear cached products +await clearProductsIOS(); + +// Get receipt for validation +const receipt = await getReceiptIOS(); + +// Get pending purchases +const pending = await getPendingPurchasesIOS(); + +// Handle promoted products +const promotedProduct = await getPromotedProductIOS(); +if (promotedProduct) { + await buyPromotedProductIOS(); +} + +// Show offer code redemption +await presentCodeRedemptionSheetIOS(); +``` + +## Android-Specific Functions + +```typescript +import { + acknowledgePurchaseAndroid, + consumePurchaseAndroid, + flushFailedPurchasesCachedAsPendingAndroid, + getPackageNameAndroid, + isFeatureSupported, + getBillingConfigAndroid, +} from 'react-native-iap'; + +// Acknowledge purchase (non-consumables, subscriptions) +await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); + +// Consume purchase (consumables) +await consumePurchaseAndroid({ token: purchase.purchaseToken }); + +// Clear failed pending purchases +await flushFailedPurchasesCachedAsPendingAndroid(); + +// Get package name +const packageName = getPackageNameAndroid(); + +// Check feature support +const supported = await isFeatureSupported('subscriptions'); + +// Get billing config +const config = await getBillingConfigAndroid(); +``` + +## Subscription Status (iOS) + +```typescript +import { + getSubscriptionStatusIOS, + getSubscriptionStatusesIOS, +} from 'react-native-iap'; + +// Get status for single product +const status = await getSubscriptionStatusIOS('com.app.sub_monthly'); + +// Get status for multiple products +const statuses = await getSubscriptionStatusesIOS(); +``` + +## Error Handling + +```typescript +import { IapIosSk2, ErrorCode } from 'react-native-iap'; + +try { + await requestPurchase({ sku: 'com.app.product1' }); +} catch (err) { + if (err.code === ErrorCode.E_USER_CANCELLED) { + // User cancelled + } else if (err.code === ErrorCode.E_ITEM_UNAVAILABLE) { + // Item not available + } else if (err.code === ErrorCode.E_ALREADY_OWNED) { + // Already owned + } else { + // Other error + } +} +``` + +### Error Codes + +| Code | Description | +|------|-------------| +| `E_UNKNOWN` | Unknown error | +| `E_USER_CANCELLED` | User cancelled | +| `E_ITEM_UNAVAILABLE` | Item not available | +| `E_NETWORK_ERROR` | Network error | +| `E_SERVICE_ERROR` | Store service error | +| `E_ALREADY_OWNED` | Item already owned | +| `E_REMOTE_ERROR` | Remote error | +| `E_NOT_PREPARED` | Not initialized | +| `E_NOT_ENDED` | Not ended | +| `E_DEVELOPER_ERROR` | Developer error | +| `E_BILLING_RESPONSE_JSON_PARSE_ERROR` | JSON parse error | +| `E_DEFERRED_PAYMENT` | Deferred payment | + +## Complete Usage Example + +```typescript +import React, { useEffect } from 'react'; +import { + withIAPContext, + useIAP, + requestPurchase, + finishTransaction, + purchaseUpdatedListener, + purchaseErrorListener, + ProductPurchase, +} from 'react-native-iap'; + +const productIds = ['com.app.product1']; +const subscriptionIds = ['com.app.sub_monthly']; + +function Store() { + const { + connected, + products, + subscriptions, + fetchProducts, + getSubscriptions, + } = useIAP(); + + useEffect(() => { + if (connected) { + fetchProducts({ skus: productIds }); + getSubscriptions({ skus: subscriptionIds }); + } + }, [connected]); + + useEffect(() => { + const purchaseSub = purchaseUpdatedListener( + async (purchase: ProductPurchase) => { + await finishTransaction({ purchase, isConsumable: false }); + } + ); + + const errorSub = purchaseErrorListener((error) => { + console.error('Purchase error:', error); + }); + + return () => { + purchaseSub.remove(); + errorSub.remove(); + }; + }, []); + + const handlePurchase = async (sku: string) => { + try { + await requestPurchase({ sku }); + } catch (err) { + console.error(err); + } + }; + + return (/* Render products and subscriptions */); +} + +export default withIAPContext(Store); +``` + +## Platform Differences + +| Feature | iOS | Android | +|---------|-----|---------| +| Subscription offers | Introductory price, Discounts | Offer tokens, Pricing phases | +| Acknowledge | Automatic | Required within 3 days | +| Consume | finishTransaction | consumePurchaseAndroid | +| Receipt | getReceiptIOS | transactionReceipt in Purchase | +| Promoted products | Supported | Not supported | +| Offer codes | Supported | Promo codes via Play Store | + + +--- + +# StoreKit 2 API Reference + +This document provides external API reference for Apple's StoreKit 2 framework. + +## iOS 18+ Features + +| Feature | iOS Version | Description | +|---------|-------------|-------------| +| Win-back offers | iOS 18.0 | Re-engage churned subscribers | +| `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 17.4 | `ExternalPurchase.presentNoticeSheet()` | +| `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) | +| `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**: `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 + +A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. + +### Important: UUID Format Requirement + +**The `appAccountToken` must be a valid UUID format.** If you provide a non-UUID string (e.g., `"user-123"` or `"my-account-id"`), Apple's StoreKit will silently return `null` for this field in the transaction response. + +#### Valid UUID Examples + +```swift +// Valid UUIDs - these will be returned correctly +"550e8400-e29b-41d4-a716-446655440000" +"6ba7b810-9dad-11d1-80b4-00c04fd430c8" +UUID().uuidString // Generate new UUID +``` + +#### Invalid Examples (Will Return null) + +```swift +// Invalid - NOT UUID format, Apple returns null silently +"user-123" +"my-account-token" +"abc123" +``` + +### Usage in Purchase Options + +```swift +let appAccountToken = UUID() +let result = try await product.purchase(options: [ + .appAccountToken(appAccountToken) +]) +``` + +### Retrieving from Transaction + +```swift +let transaction: Transaction +if let token = transaction.appAccountToken { + // Token will only be present if a valid UUID was provided during purchase + print("App Account Token: \(token)") +} +``` + +### Best Practices + +1. **Generate UUIDs per user**: Create and store a UUID for each user in your system +2. **Use consistent tokens**: Use the same UUID for all purchases from the same user +3. **Server-side mapping**: Map the UUID to your internal user ID on your server +4. **Don't use user IDs directly**: Convert your user IDs to UUIDs rather than using them directly + +### References + +- [Apple Developer Documentation: appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken) +- [GitHub Issue: expo-iap #128](https://github.com/hyochan/expo-iap/issues/128) + +## Product + +A type that describes an in-app purchase product. + +### Properties + +```swift +let id: String // The product identifier +let type: Product.ProductType // The type of product +let displayName: String // Localized display name +let description: String // Localized description +let displayPrice: String // Localized price string +let price: Decimal // Price as decimal +let subscription: Product.SubscriptionInfo? // Subscription details +``` + +### Methods + +#### products(for:) + +```swift +static func products(for identifiers: [String]) async throws -> [Product] +``` + +Fetches products from the App Store. + +#### purchase(options:) + +```swift +func purchase(options: Set = []) async throws -> Product.PurchaseResult +``` + +Initiates a purchase for this product. + +## Transaction + +Represents a completed purchase transaction. + +### Properties + +```swift +let id: UInt64 // Unique transaction ID +let originalID: UInt64 // Original transaction ID +let productID: String // Product identifier +let purchaseDate: Date // When the purchase occurred +let expirationDate: Date? // Subscription expiration date +let revocationDate: Date? // When the transaction was revoked +let isUpgraded: Bool // Whether this subscription was upgraded +let environment: AppStore.Environment // sandbox or production +``` + +### Methods + +#### currentEntitlements + +```swift +static var currentEntitlements: Transaction.Entitlements +``` + +A sequence of the customer's current entitlements. + +#### latest(for:) + +```swift +static func latest(for productID: String) async -> VerificationResult? +``` + +Gets the latest transaction for a product. + +#### finish() + +```swift +func finish() async +``` + +Marks the transaction as finished. + +## AppStore + +Provides access to App Store functionality. + +### Methods + +#### sync() + +```swift +static func sync() async throws +``` + +Syncs transactions with the App Store. + +#### showManageSubscriptions(in:) + +```swift +static func showManageSubscriptions(in scene: UIWindowScene) async throws +``` + +Shows the subscription management UI. + +#### beginRefundRequest(for:in:) + +```swift +static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScene) async throws -> Transaction.RefundRequestStatus +``` + +Begins a refund request for a transaction. + +## Win-Back Offers (iOS 18+) + +Win-back offers are a new offer type to re-engage churned subscribers. + +### Automatic Presentation + +StoreKit Message automatically presents win-back offers when a user is eligible: + +```swift +// Message reason for win-back offers +StoreKit.Message.Reason.winBackOffer +``` + +### Manual Application + +Apply a win-back offer during purchase: + +```swift +let product: Product +let winBackOffer: Product.SubscriptionOffer + +let result = try await product.purchase(options: [ + .winBackOffer(winBackOffer) +]) +``` + +### Checking Eligibility + +Discover eligible win-back offers before purchase via `Product.SubscriptionInfo.eligibleWinBackOfferIDs` (iOS 18+): + +```swift +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 ?? "") +} +``` + +### RenewalInfo + +Win-back offer information is available in renewal info: + +```swift +let renewalInfo: Product.SubscriptionInfo.RenewalInfo + +// Check if win-back offer is applied to next renewal +if renewalInfo.renewalOfferType == .winBack { + // Win-back offer will be applied +} +``` + +## UI Context for Purchases (iOS 18.2+) + +Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: + +```swift +// iOS/iPadOS/tvOS/visionOS: UIViewController +let result = try await product.purchase(confirmIn: viewController) + +// macOS: NSWindow +let result = try await product.purchase(confirmIn: window) + +// watchOS: No UI context required +``` + +> **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene. + +## AppTransaction Updates (iOS 18.4+) + +```swift +let appTransaction = try await AppTransaction.shared + +// New in iOS 18.4 (back-deployed to iOS 15) +let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account +let originalPlatform = appTransaction.originalPlatform // Original purchase platform +``` + +### appTransactionID + +- Globally unique identifier for each Apple Account that downloads your app +- Remains consistent across redownloads, refunds, repurchases, and storefront changes +- 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: + +```swift +// Check if product has advanced commerce info +if let advancedInfo = product.advancedCommerceInfo { + // Handle large catalog monetization +} +``` + +## 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 +SK2ConsumableTransactionHistory + +``` + +With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. + +## External Purchase Support (iOS 18.2+) + +### Present External Purchase Notice + +```swift +// Check if external purchase notice can be presented +if await ExternalPurchase.canPresent { + let result = try await ExternalPurchase.presentNoticeSheet() + switch result { + case .continue: + // User wants to continue to external purchase + case .dismissed: + // User dismissed the notice + } +} +``` + +### Present External Purchase Link + +```swift +let result = try await ExternalPurchase.open(url: externalURL) +``` + +> **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package. + + +--- + +## Links & Resources + +- Documentation: https://openiap.dev/docs +- Types Reference: https://openiap.dev/docs/types +- APIs Reference: https://openiap.dev/docs/apis +- Error Codes: https://openiap.dev/docs/errors +- GitHub: https://github.com/hyodotdev/openiap + +### Ecosystem Libraries +- expo-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap +- react-native-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap +- flutter_inapp_purchase: https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase +- godot-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap +- kmp-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 5efcc5e1..65965d50 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-01-20T21:43:18.697Z +> Generated: 2026-04-15T15:48:40.915Z ## Installation diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index ff0510f0..a12070ad 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -23,6 +23,92 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // Cross-platform billing-issue event - Apr 16, 2026 + { + id: 'subscription-billing-issue-event-2026-04-16', + date: new Date('2026-04-16'), + element: ( +
+ + Cross-platform subscriptionBillingIssue event - April + 16, 2026 + + +

+ OpenIAP now surfaces a single, cross-platform event when an active + subscription needs user attention for a payment problem — unifying{' '} + StoreKit 2's Message.Reason.billingIssue (iOS 18+) + and Play Billing's isSuspended signal (Billing + Library 8.1+). +

+ +
    +
  • + Schema: new{' '} + IapEvent.SubscriptionBillingIssue enum value and{' '} + subscriptionBillingIssue: Purchase! subscription in{' '} + event.graphql. Payload is the affected{' '} + Purchase. +
  • +
  • + iOS: registered via{' '} + subscriptionBillingIssueListener on{' '} + OpenIapModule. Starts a{' '} + StoreKit.Message.messages loop (iOS 18+), and on{' '} + .billingIssue scans{' '} + Transaction.currentEntitlements for subscriptions + whose renewal state is .inBillingRetryPeriod or{' '} + .inGracePeriod, emitting one event per match. Silent + no-op on iOS 17 and earlier, and on macOS / tvOS / watchOS / + visionOS. +
  • +
  • + Android (Play flavor): registered via{' '} + addSubscriptionBillingIssueListener. Fires during{' '} + getAvailablePurchases for each purchase with{' '} + isSuspendedAndroid == true; deduped by purchase token + across the session. +
  • +
  • + Android (Horizon flavor): explicit no-op — the + Horizon Billing Compatibility SDK targets Play Billing 7.0 and + does not expose a suspended-subscription signal. Calling{' '} + addSubscriptionBillingIssueListener logs a warning + and returns; the listener is never invoked. +
  • +
  • + Recommended UX: on fire, direct the user to{' '} + deepLinkToSubscriptions so they can update payment + method in the platform subscription center. +
  • +
+ +

+ Native bridge wiring for downstream libraries (react-native-iap, + expo-iap, flutter_inapp_purchase, godot-iap, kmp-iap) will land in + the next per-library release. +

+
+ ), + }, // Subscription replacement + debug message diagnostics - Apr 15, 2026 { id: 'subscription-replacement-and-debug-message-2026-04-15', diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 6f36e70a..196b95ed 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1086,6 +1086,18 @@ class OpenIapModule( Log.w(TAG, "removeDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)") } + override fun addSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { + // No-op: Suspended-subscription detection (Purchase.isSuspended) requires Google Play + // Billing Library 8.1+. The Meta Horizon Billing Compatibility SDK targets Play Billing 7.0 + // and does not expose this signal. + Log.w(TAG, "addSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op); requires Play Billing 8.1+") + } + + override fun removeSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { + // No-op: see addSubscriptionBillingIssueListener + Log.w(TAG, "removeSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op)") + } + // Billing Programs (8.2.0+, EXTERNAL_PAYMENTS 8.3.0+) - Not supported on Horizon override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt index d7b41ab4..ba0d7dea 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -5,6 +5,7 @@ import dev.hyo.openiap.listener.DeveloperProvidedBillingListener import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener /** @@ -68,6 +69,18 @@ interface OpenIapProtocol { fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) + // Subscription Billing Issues (Google Play Billing Library 8.1.0+) + /** + * Add listener for subscription billing-issue events. + * Fires once per session when a subscription is observed with isSuspended == true. + * + * - Play flavor: populated via Purchase.isSuspended (Billing 8.1+). + * - Horizon flavor: NEVER fires. The Horizon Billing Compatibility SDK targets + * Play Billing 7.0 which does not expose a suspended-subscription signal. + */ + fun addSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) + fun removeSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) + // Billing Programs (Google Play Billing Library 8.2.0+) /** * Check if a billing program is available for this user/device. diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 6479ca6f..7326529f 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -455,7 +455,14 @@ public enum class IapEvent(val rawValue: String) { * Fired when user selects developer-provided billing option in external payments flow. * Available on Android with Google Play Billing Library 8.3.0+ */ - DeveloperProvidedBillingAndroid("developer-provided-billing-android"); + DeveloperProvidedBillingAndroid("developer-provided-billing-android"), + /** + * Fired when an active subscription enters a billing-issue state that requires user attention. + * Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + * Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + * Compatibility SDK implements only the Play Billing 7.0 API surface. + */ + SubscriptionBillingIssue("subscription-billing-issue"); companion object { fun fromJson(value: String): IapEvent = when (value) { @@ -470,6 +477,8 @@ public enum class IapEvent(val rawValue: String) { "UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid "developer-provided-billing-android" -> IapEvent.DeveloperProvidedBillingAndroid "DeveloperProvidedBillingAndroid" -> IapEvent.DeveloperProvidedBillingAndroid + "subscription-billing-issue" -> IapEvent.SubscriptionBillingIssue + "SubscriptionBillingIssue" -> IapEvent.SubscriptionBillingIssue else -> throw IllegalArgumentException("Unknown IapEvent value: $value") } } @@ -4832,6 +4841,20 @@ public interface SubscriptionResolver { * Fires when a purchase completes successfully or a pending purchase resolves */ suspend fun purchaseUpdated(): Purchase + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + suspend fun subscriptionBillingIssue(): Purchase /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -4951,6 +4974,7 @@ public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails public data class SubscriptionHandlers( @@ -4958,5 +4982,6 @@ public data class SubscriptionHandlers( val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null, val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, + val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt index 1a9027cd..da3f654e 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt @@ -52,6 +52,23 @@ fun interface OpenIapDeveloperProvidedBillingListener { fun onDeveloperProvidedBilling(details: DeveloperProvidedBillingDetailsAndroid) } +/** + * Listener for subscription billing-issue events. + * Fires once per session when a previously-healthy subscription is observed in a + * suspended state (payment method failed, card expired, etc.). + * + * - Play flavor: populated via Purchase.isSuspended (Billing Library 8.1+). + * - Horizon flavor: NEVER invoked. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which lacks a suspended-subscription signal. + */ +fun interface OpenIapSubscriptionBillingIssueListener { + /** + * Called when an active subscription enters a billing-issue state + * @param purchase The affected purchase with isSuspended == true + */ + fun onSubscriptionBillingIssue(purchase: Purchase) +} + /** * Combined listener interface for convenience */ diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 436175f4..4262478b 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -110,6 +110,9 @@ class OpenIapModule( private val purchaseErrorListeners = mutableSetOf() private val userChoiceBillingListeners = mutableSetOf() private val developerProvidedBillingListeners = mutableSetOf() + private val subscriptionBillingIssueListeners = mutableSetOf() + // Track purchase tokens already reported as suspended to dedupe across queries in the same session. + private val emittedBillingIssueTokens = mutableSetOf() private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) /** @@ -239,7 +242,9 @@ class OpenIapModule( override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> withContext(Dispatchers.IO) { val includeSuspended = options?.includeSuspendedAndroid == true - restorePurchasesHelper(billingClient, includeSuspended) + val purchases = restorePurchasesHelper(billingClient, includeSuspended) + notifySuspendedSubscriptions(purchases) + purchases } } @@ -1316,6 +1321,37 @@ class OpenIapModule( developerProvidedBillingListeners.remove(listener) } + override fun addSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { + subscriptionBillingIssueListeners.add(listener) + } + + override fun removeSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { + subscriptionBillingIssueListeners.remove(listener) + } + + /** + * Inspects the given purchases and fires `subscriptionBillingIssue` once per purchaseToken + * whose `isSuspendedAndroid == true`. Dedupes across queries within the current session + * via [emittedBillingIssueTokens]; re-emits only if a token clears and re-enters suspension + * in a later session / new module instance. + */ + private fun notifySuspendedSubscriptions(purchases: List) { + if (subscriptionBillingIssueListeners.isEmpty()) return + for (purchase in purchases) { + val android = purchase as? PurchaseAndroid ?: continue + if (android.isSuspendedAndroid != true) continue + val token = android.purchaseToken ?: continue + if (!emittedBillingIssueTokens.add(token)) continue + subscriptionBillingIssueListeners.toList().forEach { listener -> + try { + listener.onSubscriptionBillingIssue(android) + } catch (t: Throwable) { + OpenIapLog.e("subscriptionBillingIssue listener threw", t, TAG) + } + } + } + } + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { OpenIapLog.d("onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}", TAG) if (purchases != null) { diff --git a/packages/gql/src/event.graphql b/packages/gql/src/event.graphql index 1444513f..3687e7c1 100644 --- a/packages/gql/src/event.graphql +++ b/packages/gql/src/event.graphql @@ -26,4 +26,18 @@ extend type Subscription { Available in Google Play Billing Library 8.3.0+ """ developerProvidedBillingAndroid: DeveloperProvidedBillingDetailsAndroid! + """ + Fires when an active subscription enters a billing-issue state that needs user action + (payment method failed, card expired, etc.). Cross-platform unification: + + - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + + Listeners should not assume the event will fire on every store. Direct users to the + platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + """ + subscriptionBillingIssue: Purchase! } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index c46f0f95..856e7a9d 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -501,7 +501,14 @@ public enum class IapEvent(val rawValue: String) { * Fired when user selects developer-provided billing option in external payments flow. * Available on Android with Google Play Billing Library 8.3.0+ */ - DeveloperProvidedBillingAndroid("developer-provided-billing-android") + DeveloperProvidedBillingAndroid("developer-provided-billing-android"), + /** + * Fired when an active subscription enters a billing-issue state that requires user attention. + * Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + * Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + * Compatibility SDK implements only the Play Billing 7.0 API surface. + */ + SubscriptionBillingIssue("subscription-billing-issue") companion object { fun fromJson(value: String): IapEvent = when (value) { @@ -520,6 +527,9 @@ public enum class IapEvent(val rawValue: String) { "developer-provided-billing-android" -> IapEvent.DeveloperProvidedBillingAndroid "DEVELOPER_PROVIDED_BILLING_ANDROID" -> IapEvent.DeveloperProvidedBillingAndroid "DeveloperProvidedBillingAndroid" -> IapEvent.DeveloperProvidedBillingAndroid + "subscription-billing-issue" -> IapEvent.SubscriptionBillingIssue + "SUBSCRIPTION_BILLING_ISSUE" -> IapEvent.SubscriptionBillingIssue + "SubscriptionBillingIssue" -> IapEvent.SubscriptionBillingIssue else -> throw IllegalArgumentException("Unknown IapEvent value: $value") } } @@ -4920,6 +4930,20 @@ public interface SubscriptionResolver { * Fires when a purchase completes successfully or a pending purchase resolves */ suspend fun purchaseUpdated(): Purchase + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + suspend fun subscriptionBillingIssue(): Purchase /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -5039,6 +5063,7 @@ public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase +public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails public data class SubscriptionHandlers( @@ -5046,5 +5071,6 @@ public data class SubscriptionHandlers( val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null, val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, + val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 72eb9f43..39db135b 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -264,6 +264,11 @@ public enum IapEvent: String, Codable, CaseIterable { /// Fired when user selects developer-provided billing option in external payments flow. /// Available on Android with Google Play Billing Library 8.3.0+ case developerProvidedBillingAndroid = "developer-provided-billing-android" + /// Fired when an active subscription enters a billing-issue state that requires user attention. + /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + /// Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + /// Compatibility SDK implements only the Play Billing 7.0 API surface. + case subscriptionBillingIssue = "subscription-billing-issue" } /// Unified purchase states from IAPKit verification response. @@ -2435,6 +2440,18 @@ public protocol SubscriptionResolver { func purchaseError() async throws -> PurchaseError /// Fires when a purchase completes successfully or a pending purchase resolves func purchaseUpdated() async throws -> Purchase + /// Fires when an active subscription enters a billing-issue state that needs user action + /// (payment method failed, card expired, etc.). Cross-platform unification: + /// + /// - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + /// on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + /// - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + /// + /// Listeners should not assume the event will fire on every store. Direct users to the + /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + func subscriptionBillingIssue() async throws -> Purchase /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails @@ -2652,6 +2669,7 @@ public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async t public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase +public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails public struct SubscriptionHandlers { @@ -2659,6 +2677,7 @@ public struct SubscriptionHandlers { public var promotedProductIOS: SubscriptionPromotedProductIOSHandler? public var purchaseError: SubscriptionPurchaseErrorHandler? public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? + public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? public init( @@ -2666,12 +2685,14 @@ public struct SubscriptionHandlers { promotedProductIOS: SubscriptionPromotedProductIOSHandler? = nil, purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, + subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS self.purchaseError = purchaseError self.purchaseUpdated = purchaseUpdated + self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 67d7d3f5..1b65274a 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -425,7 +425,12 @@ enum IapEvent { UserChoiceBillingAndroid('user-choice-billing-android'), /// Fired when user selects developer-provided billing option in external payments flow. /// Available on Android with Google Play Billing Library 8.3.0+ - DeveloperProvidedBillingAndroid('developer-provided-billing-android'); + DeveloperProvidedBillingAndroid('developer-provided-billing-android'), + /// Fired when an active subscription enters a billing-issue state that requires user attention. + /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + /// Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + /// Compatibility SDK implements only the Play Billing 7.0 API surface. + SubscriptionBillingIssue('subscription-billing-issue'); const IapEvent(this.value); final String value; @@ -443,6 +448,8 @@ enum IapEvent { return IapEvent.UserChoiceBillingAndroid; case 'developer-provided-billing-android': return IapEvent.DeveloperProvidedBillingAndroid; + case 'subscription-billing-issue': + return IapEvent.SubscriptionBillingIssue; } throw ArgumentError('Unknown IapEvent value: $value'); } @@ -4883,6 +4890,18 @@ abstract class SubscriptionResolver { Future purchaseError(); /// Fires when a purchase completes successfully or a pending purchase resolves Future purchaseUpdated(); + /// Fires when an active subscription enters a billing-issue state that needs user action + /// (payment method failed, card expired, etc.). Cross-platform unification: + /// + /// - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + /// on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + /// - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + /// + /// Listeners should not assume the event will fire on every store. Direct users to the + /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + Future subscriptionBillingIssue(); /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); @@ -5088,6 +5107,7 @@ typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function(); typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); +typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); class SubscriptionHandlers { @@ -5096,6 +5116,7 @@ class SubscriptionHandlers { this.promotedProductIOS, this.purchaseError, this.purchaseUpdated, + this.subscriptionBillingIssue, this.userChoiceBillingAndroid, }); @@ -5103,5 +5124,6 @@ class SubscriptionHandlers { final SubscriptionPromotedProductIOSHandler? promotedProductIOS; final SubscriptionPurchaseErrorHandler? purchaseError; final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; + final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index fed9bd9b..88dbdb17 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -146,6 +146,8 @@ enum IapEvent { USER_CHOICE_BILLING_ANDROID = 3, ## Fired when user selects developer-provided billing option in external payments flow. Available on Android with Google Play Billing Library 8.3.0+ DEVELOPER_PROVIDED_BILLING_ANDROID = 4, + ## Fired when an active subscription enters a billing-issue state that requires user attention. Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing Compatibility SDK implements only the Play Billing 7.0 API surface. + SUBSCRIPTION_BILLING_ISSUE = 5, } ## Unified purchase states from IAPKit verification response. @@ -4286,7 +4288,8 @@ const IAP_EVENT_VALUES = { IapEvent.PURCHASE_ERROR: "purchase-error", IapEvent.PROMOTED_PRODUCT_IOS: "promoted-product-ios", IapEvent.USER_CHOICE_BILLING_ANDROID: "user-choice-billing-android", - IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID: "developer-provided-billing-android" + IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID: "developer-provided-billing-android", + IapEvent.SUBSCRIPTION_BILLING_ISSUE: "subscription-billing-issue" } const IAPKIT_PURCHASE_STATE_VALUES = { @@ -4503,7 +4506,8 @@ const IAP_EVENT_FROM_STRING = { "purchase-error": IapEvent.PURCHASE_ERROR, "promoted-product-ios": IapEvent.PROMOTED_PRODUCT_IOS, "user-choice-billing-android": IapEvent.USER_CHOICE_BILLING_ANDROID, - "developer-provided-billing-android": IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID + "developer-provided-billing-android": IapEvent.DEVELOPER_PROVIDED_BILLING_ANDROID, + "subscription-billing-issue": IapEvent.SUBSCRIPTION_BILLING_ISSUE } const IAPKIT_PURCHASE_STATE_FROM_STRING = { diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 81b1f73b..9a4c0338 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -457,7 +457,7 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; -export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android'; +export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -1560,6 +1560,20 @@ export interface Subscription { purchaseError: PurchaseError; /** Fires when a purchase completes successfully or a pending purchase resolves */ purchaseUpdated: Purchase; + /** + * Fires when an active subscription enters a billing-issue state that needs user action + * (payment method failed, card expired, etc.). Cross-platform unification: + * + * - iOS 18+: delivered via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected + * on a previously healthy subscription. Requires Google Play Billing Library 8.1.0 or newer. + * - Android (Horizon flavor): NOT emitted. The Horizon Billing Compatibility SDK implements + * the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. + * + * Listeners should not assume the event will fire on every store. Direct users to the + * platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. + */ + subscriptionBillingIssue: Purchase; /** * Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) * Only triggered when the user selects alternative billing instead of Google Play billing @@ -1965,6 +1979,7 @@ export type SubscriptionArgsMap = { promotedProductIOS: never; purchaseError: never; purchaseUpdated: never; + subscriptionBillingIssue: never; userChoiceBillingAndroid: never; }; diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index bfefeec8..60b2175d 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -30,6 +30,13 @@ enum IapEvent { Available on Android with Google Play Billing Library 8.3.0+ """ DeveloperProvidedBillingAndroid + """ + Fired when an active subscription enters a billing-issue state that requires user attention. + Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and + Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing + Compatibility SDK implements only the Play Billing 7.0 API surface. + """ + SubscriptionBillingIssue } # Purchase verification providers From 787d04af6f54a75f470f696e9f57d0e0ac296ea0 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:08:00 +0900 Subject: [PATCH 02/21] fix(kmp-iap): override subscriptionBillingIssue + add cross-platform 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` 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) --- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 23 ++++++++++++++++++- .../kotlin/io/github/hyochan/kmpiap/KmpIap.kt | 17 ++++++++++++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 17 ++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 6952b114..dabc853f 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -141,6 +141,9 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife private val _developerProvidedBillingListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val developerProvidedBillingListener: Flow = _developerProvidedBillingListener.asSharedFlow() + private val _subscriptionBillingIssueListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val subscriptionBillingIssueListener: Flow = _subscriptionBillingIssueListener.asSharedFlow() + private fun failWith(error: PurchaseError): Nothing = emitFailureAndThrow(_purchaseErrorListener, error) @@ -564,6 +567,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife override suspend fun purchaseUpdated(): Purchase = purchaseUpdatedListener.first() + override suspend fun subscriptionBillingIssue(): Purchase = subscriptionBillingIssueListener.first() + override suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean?) { finishTransactionHandler(purchase, isConsumable) } @@ -693,10 +698,25 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val all = mutableListOf() all += query(BillingClient.ProductType.INAPP, includeSuspendedSubs = false) all += query(BillingClient.ProductType.SUBS, includeSuspendedSubs = includeSuspended) + notifySuspendedSubscriptions(all) all } } + // Tracks purchase tokens already emitted as billing-issue events so we don't re-fire + // on every getAvailablePurchases call. + private val emittedBillingIssueTokens = mutableSetOf() + + private fun notifySuspendedSubscriptions(purchases: List) { + for (purchase in purchases) { + val android = purchase as? PurchaseAndroid ?: continue + if (android.isSuspendedAndroid != true) continue + val token = android.purchaseToken ?: continue + if (!emittedBillingIssueTokens.add(token)) continue + _subscriptionBillingIssueListener.tryEmit(android) + } + } + private val getActiveSubscriptionsHandler: QueryGetActiveSubscriptionsHandler = { ids -> withContext(Dispatchers.IO) { ensureConnectedOrFail(isConnected, ::failWith) @@ -765,7 +785,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val subscriptionHandlers: SubscriptionHandlers by lazy { SubscriptionHandlers( purchaseUpdated = { purchaseUpdatedListener.first() }, - purchaseError = { purchaseErrorListener.first() } + purchaseError = { purchaseErrorListener.first() }, + subscriptionBillingIssue = { subscriptionBillingIssueListener.first() } ) } diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt index 70d03152..760b3199 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt @@ -117,6 +117,23 @@ interface KmpInAppPurchase : MutationResolver, QueryResolver, SubscriptionResolv */ val promotedProductListener: Flow + /** + * Listener for subscription billing-issue events (cross-platform). + * + * Emits once per affected purchase when: + * - iOS 18+: StoreKit delivers `Message.Reason.billingIssue` for a subscription + * whose RenewalState is `.inBillingRetryPeriod` or `.inGracePeriod`. + * - Android (Play Billing 8.1+): `getAvailablePurchases` encounters a purchase + * with `isSuspendedAndroid == true` (deduped by purchase token). + * + * NOT emitted on the Horizon flavor — the Horizon Billing Compatibility SDK + * targets Play Billing 7.0 and does not expose a suspended-subscription signal. + * + * Recommended UX: on emission, direct the user to `deepLinkToSubscriptions` + * so they can update their payment method in the platform subscription center. + */ + val subscriptionBillingIssueListener: Flow + // ===== Connection Management ===== diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 5948fdd2..bc3b7158 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -42,6 +42,17 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) override val promotedProductListener: Flow = _promotedProductFlow.asSharedFlow() + // StoreKit 2 Message.billingIssue bridge (iOS 18+). + // Reference: https://developer.apple.com/documentation/storekit/message/reason/4123328-billingissue + // Emission is driven from the Swift side via OpenIapModule.subscriptionBillingIssueListener; + // full cinterop bridge wiring lands in a follow-up release alongside the other downstream libs. + private val _subscriptionBillingIssueFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val subscriptionBillingIssueListener: Flow = _subscriptionBillingIssueFlow.asSharedFlow() + private var isConnected = false private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -724,6 +735,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { throw UnsupportedOperationException("Use promotedProductListener Flow instead") } + // Cross-platform billing-issue handler — iOS impl backed by StoreKit.Message listener. + // Reference (OpenIAP): https://openiap.dev/docs/events#subscription-billing-issue + override suspend fun subscriptionBillingIssue(): Purchase { + throw UnsupportedOperationException("Use subscriptionBillingIssueListener Flow instead") + } + // ------------------------------------------------------------------------- // Conversion Helpers // ------------------------------------------------------------------------- From e2b60cf8a6852a62f0a9b8590e6af99e67ab470f Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:15:45 +0900 Subject: [PATCH 03/21] fix(review): address PR 99 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- knowledge/_claude-context/context.md | 6 +- knowledge/external/horizon-api.md | 4 +- packages/apple/Sources/OpenIapModule.swift | 82 +++++++++++++------ .../OpenIapTests/VerifyPurchaseTests.swift | 4 + .../VerifyPurchaseWithProviderTests.swift | 4 + packages/docs/public/llms-full.txt | 6 +- packages/docs/public/llms.txt | 2 +- .../java/dev/hyo/openiap/OpenIapModule.kt | 28 +++++-- 8 files changed, 96 insertions(+), 40 deletions(-) diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 780536e8..8c470e7f 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-04-15T15:48:40.902Z +> Last updated: 2026-04-15T16:14:04.565Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -2368,7 +2368,7 @@ billingClient.launchBillingFlow(activity, params) 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 @@ -2397,7 +2397,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+) diff --git a/knowledge/external/horizon-api.md b/knowledge/external/horizon-api.md index 50897e0a..309e8d53 100644 --- a/knowledge/external/horizon-api.md +++ b/knowledge/external/horizon-api.md @@ -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 @@ -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+) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 4e9050a6..aad6edef 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -1524,12 +1524,17 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Starts the StoreKit 2 Message listener for subscription-billing-issue events. - /// `StoreKit.Message` is an iOS-only API (unavailable on macOS, tvOS, watchOS, visionOS), - /// and the `.billingIssue` reason requires iOS 18+. On older iOS versions and non-iOS - /// platforms this is a silent no-op. + /// + /// `StoreKit.Message` ships on iOS, iPadOS and Mac Catalyst (16.0+). The `.billingIssue` + /// reason requires 18.0+. On macOS, tvOS, watchOS and visionOS the Message API is not + /// available, so this method is a silent no-op there. + /// + /// References: + /// - https://developer.apple.com/documentation/storekit/message + /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue private func startMessageListener() { - #if os(iOS) && !targetEnvironment(macCatalyst) - if #available(iOS 18.0, *) { + #if os(iOS) || targetEnvironment(macCatalyst) + if #available(iOS 18.0, macCatalyst 18.0, *) { messageListenerTask?.cancel() messageListenerTask = Task { [weak self] in guard let self else { return } @@ -1544,37 +1549,64 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Resolves the affected subscription(s) from current entitlements and emits the event. - /// StoreKit's Message does not carry a transaction reference, so we cross-reference - /// `Transaction.currentEntitlements` for auto-renewable subscriptions whose - /// RenewalState indicates a billing-retry or grace-period condition. + /// + /// `StoreKit.Message` doesn't carry a transaction reference, so we cross-reference + /// `Transaction.currentEntitlements` (auto-renewable only) and emit for every subscription + /// whose `Product.SubscriptionInfo.status` array contains an entry in `.inBillingRetryPeriod` + /// or `.inGracePeriod`. The status array is unordered across subscription-group members, so + /// we must iterate every element rather than inspect `.first`. Product lookups are batched + /// into a single `Product.products(for:)` call per message. + /// + /// Reference: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/status(for:) @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) private func dispatchBillingIssueMessage() async { - var emitted = false + var entitlements: [(Transaction, String)] = [] for await verification in Transaction.currentEntitlements { guard case .verified(let transaction) = verification, transaction.productType == .autoRenewable else { continue } + entitlements.append((transaction, verification.jwsRepresentation)) + } + guard !entitlements.isEmpty else { + OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no auto-renewable entitlements present") + return + } + + let productIds = Array(Set(entitlements.map { $0.0.productID })) + let products: [StoreKit.Product] + do { + products = try await StoreKit.Product.products(for: productIds) + } catch { + OpenIapLog.debug("🔔 [MessageListener] Product.products(for:) failed: \(error.localizedDescription)") + return + } + let productBySku: [String: StoreKit.Product] = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) }) + + var emitted = false + for (transaction, jws) in entitlements { + guard let subscription = productBySku[transaction.productID]?.subscription else { continue } + let statusArray: [StoreKit.Product.SubscriptionInfo.Status] do { - guard let product = try await StoreKit.Product.products(for: [transaction.productID]).first, - let subscription = product.subscription else { continue } - let statusArray = try await subscription.status - guard let latest = statusArray.first else { continue } - switch latest.state { - case .inBillingRetryPeriod, .inGracePeriod: - let purchase = await StoreKitTypesBridge.purchase( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - emitSubscriptionBillingIssue(purchase) - emitted = true - default: - continue - } + statusArray = try await subscription.status } catch { continue } + var hasBillingIssue = false + for status in statusArray { + if status.state == .inBillingRetryPeriod || status.state == .inGracePeriod { + hasBillingIssue = true + break + } + } + guard hasBillingIssue else { continue } + let purchase = await StoreKitTypesBridge.purchase( + from: transaction, + jwsRepresentation: jws + ) + emitSubscriptionBillingIssue(purchase) + emitted = true } if !emitted { - OpenIapLog.debug("🔔 [MessageListener] billingIssue message received but no matching subscription found in retry/grace state") + OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no subscription currently reports retry/grace state") } } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift index ebfe50f4..ad7b3f96 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift @@ -150,6 +150,10 @@ private final class FakeOpenIapModule: OpenIapModuleProtocol { Subscription(eventType: .promotedProductIos) } + func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + Subscription(eventType: .subscriptionBillingIssue) + } + func removeListener(_ subscription: Subscription) { subscription.onRemove?() } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 11cf9515..ae247d5a 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -165,6 +165,10 @@ private final class FakeVerifyPurchaseModule: OpenIapModuleProtocol { Subscription(eventType: .promotedProductIos) } + func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + Subscription(eventType: .subscriptionBillingIssue) + } + func removeListener(_ subscription: Subscription) { subscription.onRemove?() } diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 8fa58b1f..7928b99e 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-04-15T15:48:40.915Z +> Generated: 2026-04-15T16:14:04.578Z ## Table of Contents 1. Installation @@ -880,7 +880,7 @@ billingClient.launchBillingFlow(activity, params) 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 @@ -909,7 +909,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+) diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 65965d50..c02a38ef 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-04-15T15:48:40.915Z +> Generated: 2026-04-15T16:14:04.578Z ## Installation diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 4262478b..bff108ab 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -110,9 +110,14 @@ class OpenIapModule( private val purchaseErrorListeners = mutableSetOf() private val userChoiceBillingListeners = mutableSetOf() private val developerProvidedBillingListeners = mutableSetOf() - private val subscriptionBillingIssueListeners = mutableSetOf() - // Track purchase tokens already reported as suspended to dedupe across queries in the same session. - private val emittedBillingIssueTokens = mutableSetOf() + // Thread-safe: listeners can be added/removed on the main thread while + // notifySuspendedSubscriptions iterates from Dispatchers.IO. + private val subscriptionBillingIssueListeners = + java.util.concurrent.CopyOnWriteArraySet() + // Dedup tokens across the session. ConcurrentHashMap.newKeySet() gives us an atomic + // add() that returns false when the token was already present. + private val emittedBillingIssueTokens = + java.util.concurrent.ConcurrentHashMap.newKeySet() private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) /** @@ -242,9 +247,16 @@ class OpenIapModule( override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> withContext(Dispatchers.IO) { val includeSuspended = options?.includeSuspendedAndroid == true - val purchases = restorePurchasesHelper(billingClient, includeSuspended) + // Always query suspended subs so the billing-issue notifier sees them even when the + // caller asked to hide suspended from the returned list. See: + // https://developer.android.com/google/play/billing/subscriptions#suspended + val purchases = restorePurchasesHelper(billingClient, includeSuspended = true) notifySuspendedSubscriptions(purchases) - purchases + if (includeSuspended) { + purchases + } else { + purchases.filterNot { (it as? PurchaseAndroid)?.isSuspendedAndroid == true } + } } } @@ -1341,8 +1353,11 @@ class OpenIapModule( val android = purchase as? PurchaseAndroid ?: continue if (android.isSuspendedAndroid != true) continue val token = android.purchaseToken ?: continue + // ConcurrentHashMap.newKeySet().add returns false if the token is already present, + // giving us atomic test-and-register per session. if (!emittedBillingIssueTokens.add(token)) continue - subscriptionBillingIssueListeners.toList().forEach { listener -> + // CopyOnWriteArraySet is safe to iterate concurrently with add/remove. + for (listener in subscriptionBillingIssueListeners) { try { listener.onSubscriptionBillingIssue(android) } catch (t: Throwable) { @@ -1395,6 +1410,7 @@ class OpenIapModule( purchase.toPurchase(productType, basePlanId) } OpenIapLog.d("Mapped purchases=${gson.toJson(mapped)}", TAG) + notifySuspendedSubscriptions(mapped) for (converted in mapped) { for (listener in purchaseUpdateListeners) { runCatching { listener.onPurchaseUpdated(converted) } From f4d360c0200ad301780dafbdbea96e94569ef701 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:26:02 +0900 Subject: [PATCH 04/21] feat(rn-iap): wire subscriptionBillingIssue listener (Nitro + JS + iOS + Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 37 +++++++++ .../react-native-iap/ios/HybridRnIap.swift | 34 +++++++- libraries/react-native-iap/src/index.ts | 77 +++++++++++++++++++ .../react-native-iap/src/specs/RnIap.nitro.ts | 23 ++++++ 4 files changed, 170 insertions(+), 1 deletion(-) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 59603667..a41f3f18 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -84,6 +84,7 @@ class HybridRnIap : HybridRnIapSpec() { private val promotedProductListenersIOS = mutableListOf<(NitroProduct) -> Unit>() private val userChoiceBillingListenersAndroid = mutableListOf<(UserChoiceBillingDetails) -> Unit>() private val developerProvidedBillingListenersAndroid = mutableListOf<(DeveloperProvidedBillingDetailsAndroid) -> Unit>() + private val subscriptionBillingIssueListeners = mutableListOf<(NitroPurchase) -> Unit>() private var listenersAttached = false private var isInitialized = false private var initDeferred: CompletableDeferred? = null @@ -1685,6 +1686,42 @@ class HybridRnIap : HybridRnIapSpec() { snapshot.forEach { it(details) } } + // ------------------------------------------------------------------------- + // Subscription billing-issue listener (cross-platform event) + // Source: Play Billing 8.1+ Purchase.isSuspended detection inside openiap-google. + // ------------------------------------------------------------------------- + + private var subscriptionBillingIssueAttached = false + + override fun addSubscriptionBillingIssueListener(listener: (purchase: NitroPurchase) -> Unit) { + synchronized(subscriptionBillingIssueListeners) { + subscriptionBillingIssueListeners.add(listener) + } + attachSubscriptionBillingIssueIfNeeded() + } + + override fun removeSubscriptionBillingIssueListener(listener: (purchase: NitroPurchase) -> Unit) { + synchronized(subscriptionBillingIssueListeners) { + subscriptionBillingIssueListeners.remove(listener) + } + } + + private fun attachSubscriptionBillingIssueIfNeeded() { + if (subscriptionBillingIssueAttached) return + subscriptionBillingIssueAttached = true + openIap.addSubscriptionBillingIssueListener( + dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener { purchase -> + runCatching { + val nitro = convertToNitroPurchase(purchase) + val snapshot = synchronized(subscriptionBillingIssueListeners) { + ArrayList(subscriptionBillingIssueListeners) + } + snapshot.forEach { it(nitro) } + }.onFailure { RnIapLog.failure("subscriptionBillingIssueListener", it) } + } + ) + } + // ------------------------------------------------------------------------- // Billing Programs API (Android 8.2.0+) // ------------------------------------------------------------------------- diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index e4220ac0..a64847de 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -17,6 +17,8 @@ class HybridRnIap: HybridRnIapSpec { private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] + private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = [] + private var subscriptionBillingIssueSub: Subscription? private var lastPurchaseErrorKey: String? = nil private var lastPurchaseErrorTimestamp: TimeInterval = 0 private var deliveredPurchaseEventKeys: Set = [] @@ -922,7 +924,16 @@ class HybridRnIap: HybridRnIapSpec { func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { listenerLock.withLock { purchaseErrorListeners.removeAll() } } - + + func addSubscriptionBillingIssueListener(listener: @escaping (NitroPurchase) -> Void) throws { + listenerLock.withLock { subscriptionBillingIssueListeners.append(listener) } + attachSubscriptionBillingIssueSubIfNeeded() + } + + func removeSubscriptionBillingIssueListener(listener: @escaping (NitroPurchase) -> Void) throws { + listenerLock.withLock { subscriptionBillingIssueListeners.removeAll() } + } + // MARK: - Private Helper Methods private func attachListenersIfNeeded() { @@ -1004,6 +1015,27 @@ class HybridRnIap: HybridRnIapSpec { } } + private func attachSubscriptionBillingIssueSubIfNeeded() { + guard subscriptionBillingIssueSub == nil else { return } + RnIapLog.payload("subscriptionBillingIssueListener.register", nil) + subscriptionBillingIssueSub = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] openIapPurchase in + guard let self else { + RnIapLog.warn("subscriptionBillingIssueListener: HybridRnIap deallocated, event dropped") + return + } + Task { @MainActor in + let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(openIapPurchase)) + RnIapLog.result("subscriptionBillingIssueListener", payload) + let nitro = RnIapHelper.convertPurchaseDictionary(payload) + let snapshot: [(NitroPurchase) -> Void] = self.listenerLock.withLock { + Array(self.subscriptionBillingIssueListeners) + } + for l in snapshot { l(nitro) } + } + } + RnIapLog.result("subscriptionBillingIssueListener.register", "attached") + } + private func ensureConnection() throws { guard isInitialized else { throw OpenIapException.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.") diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 57478368..e2cb2b73 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -279,12 +279,14 @@ export const resetListenerState = (): void => { promotedProductNativeAttached = false; userChoiceBillingNativeAttached = false; developerProvidedBillingNativeAttached = false; + subscriptionBillingIssueNativeAttached = false; // Clear all JS listeners since native side clears them in endConnection purchaseUpdateJsListeners.clear(); purchaseErrorJsListeners.clear(); promotedProductJsListeners.clear(); userChoiceBillingJsListeners.clear(); developerProvidedBillingJsListeners.clear(); + subscriptionBillingIssueJsListeners.clear(); }; export const purchaseUpdatedListener = ( @@ -565,6 +567,81 @@ export const developerProvidedBillingListenerAndroid = ( }; }; +/** + * Listen for subscription billing-issue events (cross-platform). + * + * Fires when an active subscription enters a billing-issue state: + * - iOS 18+ / Mac Catalyst 18+: via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play Billing 8.1+): when `isSuspendedAndroid === true` is observed. + * - Horizon / iOS 17 / older platforms: never fires. + * + * Recommended UX: on fire, call `deepLinkToSubscriptions()` so the user can + * update their payment method in the platform subscription center. + * + * @param listener - Function to call with the affected Purchase + * @returns EventSubscription with remove() method to unsubscribe + * + * @example + * ```typescript + * const subscription = subscriptionBillingIssueListener((purchase) => { + * console.warn('Subscription needs attention:', purchase.productId); + * deepLinkToSubscriptions({skuAndroid: purchase.productId, packageNameAndroid: 'com.example.app'}); + * }); + * + * subscription.remove(); + * ``` + */ +type NitroSubscriptionBillingIssueListener = Parameters< + RnIap['addSubscriptionBillingIssueListener'] +>[0]; + +const subscriptionBillingIssueJsListeners = new Set<(purchase: Purchase) => void>(); +let subscriptionBillingIssueNativeAttached = false; +const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListener = + (nitroPurchase) => { + const purchase = convertNitroPurchaseToPurchase(nitroPurchase); + for (const listener of subscriptionBillingIssueJsListeners) { + try { + listener(purchase); + } catch (e) { + RnIapConsole.error( + '[subscriptionBillingIssueListener] callback threw:', + e, + ); + } + } + }; + +export const subscriptionBillingIssueListener = ( + listener: (purchase: Purchase) => void, +): EventSubscription => { + subscriptionBillingIssueJsListeners.add(listener); + + if (!subscriptionBillingIssueNativeAttached) { + try { + IAP.instance.addSubscriptionBillingIssueListener( + subscriptionBillingIssueNativeHandler, + ); + subscriptionBillingIssueNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[subscriptionBillingIssueListener] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } + } + } + + return { + remove: () => { + subscriptionBillingIssueJsListeners.delete(listener); + }, + }; +}; + // ------------------------------ // Query API // ------------------------------ diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index e7c88e45..2c90288f 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -1095,6 +1095,29 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, ): void; + /** + * Add a listener for subscription billing-issue events (cross-platform). + * + * Fires when a user's active subscription enters a state that needs attention + * (payment method failed, card expired, etc.). Unifies: + * - StoreKit 2 `Message.Reason.billingIssue` (iOS 18+ / Mac Catalyst 18+) + * - Google Play Billing `Purchase.isSuspended` (Play Billing 8.1+) + * + * NOT fired on Meta Horizon (Billing 7.0 compat SDK lacks the suspended signal). + * + * @param listener - Called with the affected Purchase + */ + addSubscriptionBillingIssueListener( + listener: (purchase: NitroPurchase) => void, + ): void; + + /** + * Remove a subscription billing-issue listener. + */ + removeSubscriptionBillingIssueListener( + listener: (purchase: NitroPurchase) => void, + ): void; + // ╔════════════════════════════════════════════════════════════════════════╗ // ║ BILLING PROGRAMS API (Android 8.2.0+) ║ // ╚════════════════════════════════════════════════════════════════════════╝ From 1dae6627721b9fd2c5dd419878f5ffdd472f7684 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:29:43 +0900 Subject: [PATCH 05/21] feat(expo-iap): wire subscriptionBillingIssue event (JS + iOS + Android) - 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) --- .../java/expo/modules/iap/ExpoIapHelper.kt | 16 +++++++++ .../java/expo/modules/iap/ExpoIapModule.kt | 10 +++++- libraries/expo-iap/ios/ExpoIapHelper.swift | 16 +++++++-- libraries/expo-iap/ios/ExpoIapModule.swift | 3 +- libraries/expo-iap/src/index.ts | 34 +++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt index e5bb9a9e..54bdb9a5 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt @@ -227,6 +227,7 @@ object ExpoIapHelper { eventPurchaseError: String, eventUserChoiceBilling: String, eventDeveloperProvidedBilling: String, + eventSubscriptionBillingIssue: String, ) { openIap.addPurchaseUpdateListener { p -> runCatching { @@ -322,6 +323,21 @@ object ExpoIapHelper { "DEVELOPER_PROVIDED_BILLING", ) } + // Subscription billing-issue listener (Play Billing 8.1+ isSuspended; no-op on Horizon) + openIap.addSubscriptionBillingIssueListener { purchase -> + safeEmitEvent( + module, + scope, + connectionReady, + pendingEvents, + eventSubscriptionBillingIssue, + purchase.toJson(), + eventPurchaseError, + "subscription-billing-issue-error", + "Failed to process subscription billing issue", + "SUBSCRIPTION_BILLING_ISSUE", + ) + } } fun cleanupListeners(openIap: OpenIapModule) { diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index 37c52420..a5c645f9 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -48,6 +48,7 @@ class ExpoIapModule : Module() { private const val EVENT_PURCHASE_ERROR = "purchase-error" private const val EVENT_USER_CHOICE_BILLING = "user-choice-billing-android" private const val EVENT_DEVELOPER_PROVIDED_BILLING = "developer-provided-billing-android" + private const val EVENT_SUBSCRIPTION_BILLING_ISSUE = "subscription-billing-issue" private const val MAX_BUFFERED_EVENTS = 200 } @@ -75,7 +76,13 @@ class ExpoIapModule : Module() { OpenIapError.getAllErrorCodes() } - Events(EVENT_PURCHASE_UPDATED, EVENT_PURCHASE_ERROR, EVENT_USER_CHOICE_BILLING, EVENT_DEVELOPER_PROVIDED_BILLING) + Events( + EVENT_PURCHASE_UPDATED, + EVENT_PURCHASE_ERROR, + EVENT_USER_CHOICE_BILLING, + EVENT_DEVELOPER_PROVIDED_BILLING, + EVENT_SUBSCRIPTION_BILLING_ISSUE, + ) AsyncFunction("initConnection") { config: Map?, promise: Promise -> ExpoIapLog.payload("initConnection", config) @@ -114,6 +121,7 @@ class ExpoIapModule : Module() { EVENT_PURCHASE_ERROR, EVENT_USER_CHOICE_BILLING, EVENT_DEVELOPER_PROVIDED_BILLING, + EVENT_SUBSCRIPTION_BILLING_ISSUE, ) } diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 853aea4d..4d077821 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -129,7 +129,8 @@ enum ExpoIapHelper { module: ExpoIapModule, purchaseUpdated: @escaping (Purchase) -> Void, purchaseError: @escaping (PurchaseError) -> Void, - promotedProduct: @escaping (String) async -> Void + promotedProduct: @escaping (String) async -> Void, + subscriptionBillingIssue: @escaping (Purchase) -> Void ) { // Clean up any existing listeners first cleanupListeners() @@ -152,7 +153,13 @@ enum ExpoIapHelper { } } - listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub] + let billingIssueSub = OpenIapModule.shared.subscriptionBillingIssueListener { purchase in + Task { @MainActor in + subscriptionBillingIssue(purchase) + } + } + + listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub] } static func cleanupListeners() { @@ -190,6 +197,11 @@ enum ExpoIapHelper { OpenIapEvent.promotedProductIos.rawValue, ["productId": productId] ) + }, + subscriptionBillingIssue: { [weak module] purchase in + guard let module else { return } + let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + module.sendEvent(OpenIapEvent.subscriptionBillingIssue.rawValue, payload) } ) } diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 64b11c44..3c803104 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -21,7 +21,8 @@ public final class ExpoIapModule: Module { Events( OpenIapEvent.purchaseUpdated.rawValue, OpenIapEvent.purchaseError.rawValue, - OpenIapEvent.promotedProductIos.rawValue + OpenIapEvent.promotedProductIos.rawValue, + OpenIapEvent.subscriptionBillingIssue.rawValue ) OnCreate { diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 0af7ebb2..806b8f14 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -59,6 +59,12 @@ export enum OpenIapEvent { * Only available in Japan. Contains externalTransactionToken for reporting. */ DeveloperProvidedBillingAndroid = 'developer-provided-billing-android', + /** + * Fired when an active subscription enters a billing-issue state (cross-platform). + * Unifies StoreKit 2 `Message.Reason.billingIssue` (iOS 18+) and Play Billing 8.1+ + * `Purchase.isSuspended`. NOT fired on the Meta Horizon flavor. + */ + SubscriptionBillingIssue = 'subscription-billing-issue', } type ExpoIapEventPayloads = { @@ -67,6 +73,7 @@ type ExpoIapEventPayloads = { [OpenIapEvent.PromotedProductIOS]: Product; [OpenIapEvent.UserChoiceBillingAndroid]: UserChoiceBillingDetails; [OpenIapEvent.DeveloperProvidedBillingAndroid]: DeveloperProvidedBillingDetailsAndroid; + [OpenIapEvent.SubscriptionBillingIssue]: Purchase; }; type ExpoIapEventListener = ( @@ -287,6 +294,33 @@ export const developerProvidedBillingListenerAndroid = ( ); }; +/** + * Listen for subscription billing-issue events (cross-platform). + * + * Fires when a user's active subscription enters a state that needs attention + * for a payment problem. Unifies: + * - iOS 18+ / Mac Catalyst 18+: StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play Billing 8.1+): when `Purchase.isSuspendedAndroid === true`. + * - Meta Horizon / iOS 17 / older platforms: never fires. + * + * Recommended UX: call `deepLinkToSubscriptions()` when this fires so the user + * can update their payment method in the platform subscription center. + * + * @example + * ```typescript + * const subscription = subscriptionBillingIssueListener((purchase) => { + * console.warn('Needs attention:', purchase.productId); + * deepLinkToSubscriptions({ + * skuAndroid: purchase.productId, + * packageNameAndroid: 'com.example.app', + * }); + * }); + * ``` + */ +export const subscriptionBillingIssueListener = ( + listener: (purchase: Purchase) => void, +) => emitter.addListener(OpenIapEvent.SubscriptionBillingIssue, listener); + export const initConnection: MutationField<'initConnection'> = async (config) => ExpoIapModule.initConnection(config ?? null); From 2f4fb1cab7b5129fb83a4d24004dd0a7aa00b805 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:32:27 +0900 Subject: [PATCH 06/21] feat(flutter): wire subscriptionBillingIssue stream (Dart + iOS + Android) - lib/flutter_inapp_purchase.dart: add subscriptionBillingIssueListener Stream 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) --- .../AndroidInappPurchasePlugin.kt | 12 +++++++ .../Classes/FlutterInappPurchasePlugin.swift | 16 +++++++++- .../lib/flutter_inapp_purchase.dart | 31 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index b9abb8d7..4926bd36 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -1284,6 +1284,18 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } }) + openIap?.addSubscriptionBillingIssueListener( + dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener { purchase -> + scope.launch { + try { + val payload = JSONObject(purchase.toJson()) + channel?.invokeMethod("subscription-billing-issue", payload.toString()) + } catch (e: Exception) { + OpenIapLog.e("Failed to send subscription-billing-issue", e) + } + } + } + ) } companion object { diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index 00633919..1057813f 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -16,6 +16,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private var purchaseUpdatedToken: OpenIAP.Subscription? private var purchaseErrorToken: OpenIAP.Subscription? private var promotedProductToken: OpenIAP.Subscription? + private var subscriptionBillingIssueToken: OpenIAP.Subscription? // No local StoreKit caches; OpenIAP handles state internally private var processedTransactionIds: Set = [] @@ -357,15 +358,28 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { self.channel?.invokeMethod("iap-promoted-product", arguments: productId) } } + + subscriptionBillingIssueToken = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] purchase in + Task { @MainActor in + guard let self else { return } + FlutterIapLog.debug("subscriptionBillingIssueListener fired for \(purchase.productId)") + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + if let jsonString = FlutterIapHelper.jsonString(from: payload) { + self.channel?.invokeMethod("subscription-billing-issue", arguments: jsonString) + } + } + } } - + private func removeOpenIapListeners() { if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) } if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } if let token = promotedProductToken { OpenIapModule.shared.removeListener(token) } + if let token = subscriptionBillingIssueToken { OpenIapModule.shared.removeListener(token) } purchaseUpdatedToken = nil purchaseErrorToken = nil promotedProductToken = nil + subscriptionBillingIssueToken = nil } // All transaction event handling is routed via OpenIapModule listeners diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index e0a2f44d..e1f0e9af 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -97,6 +97,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final StreamController _developerProvidedBillingAndroidListener = StreamController< gentype.DeveloperProvidedBillingDetailsAndroid>.broadcast(); + final StreamController _subscriptionBillingIssueListener = + StreamController.broadcast(); /// Purchase updated event stream Stream get purchaseUpdatedListener => @@ -116,6 +118,15 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { get developerProvidedBillingAndroid => _developerProvidedBillingAndroidListener.stream; + /// Subscription billing-issue event stream (cross-platform). + /// + /// Emits when an active subscription needs user attention for a payment + /// problem. Unifies StoreKit 2 `Message.Reason.billingIssue` (iOS 18+) and + /// Google Play Billing `Purchase.isSuspended` (Play Billing 8.1+). NOT + /// emitted on the Meta Horizon flavor (Billing 7.0 compat lacks the signal). + Stream get subscriptionBillingIssueListener => + _subscriptionBillingIssueListener.stream; + bool _isInitialized = false; Future _setPurchaseListener() async { @@ -208,6 +219,26 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace'); } break; + case 'subscription-billing-issue': + try { + Map result = + jsonDecode(call.arguments as String) as Map; + final purchase = convertToPurchase( + result, + originalJson: result, + platformIsAndroid: _platform.isAndroid, + platformIsIOS: _platform.isIOS || _platform.isMacOS, + acknowledgedAndroidPurchaseTokens: + _acknowledgedAndroidPurchaseTokens, + ); + _subscriptionBillingIssueListener.add(purchase); + } catch (e, stackTrace) { + debugPrint( + '[flutter_inapp_purchase] ERROR in subscription-billing-issue: $e', + ); + debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace'); + } + break; default: throw ArgumentError('Unknown method ${call.method}'); } From 2adeabcf8c345ec7df60a93f1b236132e90f42f6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:37:28 +0900 Subject: [PATCH 07/21] feat(godot-iap): wire subscription_billing_issue signal (GDScript + Android + 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) --- .../godot-iap/addons/godot-iap/godot_iap.gd | 23 +++++++++++ .../main/java/dev/hyo/godotiap/GodotIap.kt | 11 +++++- .../Sources/GodotIap/GodotIap.swift | 38 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index bafc92b3..7f1fb6c1 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -24,6 +24,14 @@ signal promoted_product_ios(product_id: String) signal user_choice_billing_android(details: Dictionary) signal developer_provided_billing_android(details: Dictionary) +## Subscription billing-issue event (cross-platform). +## +## Emitted when an active subscription needs user attention for a payment +## problem. Unifies StoreKit 2 [code]Message.Reason.billingIssue[/code] (iOS 18+) +## and Google Play Billing [code]Purchase.isSuspended[/code] (Play Billing 8.1+). +## Not emitted on the Meta Horizon flavor. +signal subscription_billing_issue(purchase: Dictionary) + # Native plugin reference var _native_plugin: Object = null var _is_connected: bool = false @@ -86,6 +94,9 @@ func _connect_signals_ios() -> void: if _native_plugin.has_signal("promoted_product"): _native_plugin.connect("promoted_product", _on_native_promoted_product_ios) + if _native_plugin.has_signal("subscription_billing_issue"): + _native_plugin.connect("subscription_billing_issue", _on_native_subscription_billing_issue_ios) + func _connect_signals_android() -> void: if not _native_plugin: return @@ -120,6 +131,10 @@ func _connect_signals_android() -> void: _native_plugin.connect("developer_provided_billing", _on_android_developer_provided_billing) print("[GodotIap] Connected: developer_provided_billing") + if _native_plugin.has_signal("subscription_billing_issue"): + _native_plugin.connect("subscription_billing_issue", _on_android_subscription_billing_issue) + print("[GodotIap] Connected: subscription_billing_issue") + print("[GodotIap] Android signal connection complete") # ========================================== @@ -145,6 +160,9 @@ func _on_disconnected(_status_code: int = 0) -> void: func _on_native_promoted_product_ios(product_id: String) -> void: promoted_product_ios.emit(product_id) +func _on_native_subscription_billing_issue_ios(purchase: Dictionary) -> void: + subscription_billing_issue.emit(purchase) + # ========================================== # Signal Handlers - Android (JSON strings) # ========================================== @@ -173,6 +191,11 @@ func _on_android_developer_provided_billing(details_json: String) -> void: if details is Dictionary: developer_provided_billing_android.emit(details) +func _on_android_subscription_billing_issue(purchase_json: String) -> void: + var purchase = JSON.parse_string(purchase_json) + if purchase is Dictionary: + subscription_billing_issue.emit(purchase) + # ========================================== # Connection (OpenIAP Mutation) # ========================================== diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index c97ea707..eb682369 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -5,6 +5,7 @@ import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener import kotlinx.coroutines.runBlocking import org.godotengine.godot.Godot import org.godotengine.godot.plugin.GodotPlugin @@ -41,6 +42,12 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { emitSignal("purchase_error", JSONObject(errorPayload).toString()) } + private val subscriptionBillingIssueListener = OpenIapSubscriptionBillingIssueListener { purchase -> + GodotIapLog.debug("subscription billing issue: ${purchase.productId}") + val sanitized = GodotIapHelper.sanitizeDictionary(purchase.toJson()) + emitSignal("subscription_billing_issue", JSONObject(sanitized).toString()) + } + override fun getPluginName(): String = "GodotIap" override fun getPluginSignals(): Set { @@ -51,7 +58,8 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { SignalInfo("connected"), SignalInfo("disconnected"), SignalInfo("user_choice_billing", String::class.java), - SignalInfo("developer_provided_billing", String::class.java) + SignalInfo("developer_provided_billing", String::class.java), + SignalInfo("subscription_billing_issue", String::class.java) ) } @@ -75,6 +83,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { store = OpenIapStore(openIap) store.addPurchaseUpdateListener(purchaseUpdateListener) store.addPurchaseErrorListener(purchaseErrorListener) + openIap.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener) val result = store.initConnection() isInitialized = result diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index a9bfbe41..15d9e426 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -55,12 +55,16 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Signal("productId") var promotedProduct: SignalWithArguments + @Signal("resultDict") + var subscriptionBillingIssue: SignalWithArguments + // MARK: - Properties private let openIap = OpenIapModule.shared private var isConnected: Bool = false private var purchaseUpdateSubscription: Subscription? private var purchaseErrorSubscription: Subscription? private var promotedProductSubscription: Subscription? + private var subscriptionBillingIssueSubscription: Subscription? // MARK: - Initialization required init(_ context: InitContext) { @@ -1080,6 +1084,12 @@ public class GodotIap: RefCounted, @unchecked Sendable { self?.promotedProduct.emit(productId) } } + + subscriptionBillingIssueSubscription = openIap.subscriptionBillingIssueListener { [weak self] purchase in + Task { @MainActor in + self?.emitSubscriptionBillingIssue(purchase: purchase) + } + } } private func removeListeners() { @@ -1095,6 +1105,10 @@ public class GodotIap: RefCounted, @unchecked Sendable { openIap.removeListener(sub) promotedProductSubscription = nil } + if let sub = subscriptionBillingIssueSubscription { + openIap.removeListener(sub) + subscriptionBillingIssueSubscription = nil + } } @MainActor @@ -1143,6 +1157,30 @@ public class GodotIap: RefCounted, @unchecked Sendable { self.purchaseUpdated.emit(dict) } + @MainActor + private func emitSubscriptionBillingIssue(purchase: Purchase) { + let dict = VariantDictionary() + dict["productId"] = Variant(purchase.productId) + dict["purchaseState"] = Variant(purchase.purchaseState.rawValue) + switch purchase { + case .purchaseIos(let p): + dict["id"] = Variant(p.id) + dict["transactionId"] = Variant(p.transactionId) + dict["transactionDate"] = Variant(p.transactionDate) + dict["store"] = Variant(p.store.rawValue) + case .purchaseAndroid(let p): + dict["id"] = Variant(p.id) + dict["transactionId"] = Variant(p.transactionId ?? "") + dict["transactionDate"] = Variant(p.transactionDate) + dict["store"] = Variant(p.store.rawValue) + } + if let jsonData = try? JSONSerialization.data(withJSONObject: purchaseToDictionary(purchase)), + let jsonString = String(data: jsonData, encoding: .utf8) { + dict["purchaseJson"] = Variant(jsonString) + } + self.subscriptionBillingIssue.emit(dict) + } + @MainActor private func emitPurchaseError(code: String, message: String) { let dict = VariantDictionary() From 0133b8aa3b95a6f62306bfac827e753b110fe7a2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:39:12 +0900 Subject: [PATCH 08/21] feat(kmp-iap): wire iOS subscriptionBillingIssue Flow via cinterop 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) --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index bc3b7158..6bdb0241 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first import platform.Foundation.* import cocoapods.openiap.* import platform.darwin.NSObject @@ -60,6 +61,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { private var purchaseSubscription: NSObject? = null private var errorSubscription: NSObject? = null private var promotedProductSubscription: NSObject? = null + private var subscriptionBillingIssueSubscription: NSObject? = null init { // Register listeners @@ -101,6 +103,17 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { _promotedProductFlow.emit(sku) } } + + // Subscription billing-issue listener (iOS 18+ Message.billingIssue via OpenIapModule) + subscriptionBillingIssueSubscription = openIapModule.addSubscriptionBillingIssueListener { dictionary -> + println("[KMP-IAP iOS] subscriptionBillingIssue received: $dictionary") + val purchase = convertAnyToPurchase(dictionary) + if (purchase != null) { + coroutineScope.launch { + _subscriptionBillingIssueFlow.emit(purchase) + } + } + } } override fun getVersion(): String = "KMP-IAP v1.0.0-rc.2 (iOS)" @@ -133,6 +146,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { purchaseSubscription?.let { openIapModule.removeListener(it) } errorSubscription?.let { openIapModule.removeListener(it) } promotedProductSubscription?.let { openIapModule.removeListener(it) } + subscriptionBillingIssueSubscription?.let { openIapModule.removeListener(it) } openIapModule.endConnectionWithCompletion { success, error -> if (error != null) { @@ -735,11 +749,11 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { throw UnsupportedOperationException("Use promotedProductListener Flow instead") } - // Cross-platform billing-issue handler — iOS impl backed by StoreKit.Message listener. + // Cross-platform billing-issue handler — iOS impl backed by StoreKit.Message listener + // via openIapModule.addSubscriptionBillingIssueListener. Consumers should collect + // `subscriptionBillingIssueListener` (Flow) rather than invoking this directly. // Reference (OpenIAP): https://openiap.dev/docs/events#subscription-billing-issue - override suspend fun subscriptionBillingIssue(): Purchase { - throw UnsupportedOperationException("Use subscriptionBillingIssueListener Flow instead") - } + override suspend fun subscriptionBillingIssue(): Purchase = subscriptionBillingIssueListener.first() // ------------------------------------------------------------------------- // Conversion Helpers From f98c479cc3ce536391bd3ba44c6ed2ddf7ad9a24 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 01:45:28 +0900 Subject: [PATCH 09/21] docs(docs-site): add Subscription Billing Issue feature page 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) --- .../features/subscription-billing-issue.tsx | 217 ++++++++++++++++++ packages/docs/src/pages/docs/index.tsx | 14 ++ 2 files changed, 231 insertions(+) create mode 100644 packages/docs/src/pages/docs/features/subscription-billing-issue.tsx diff --git a/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx new file mode 100644 index 00000000..19653e33 --- /dev/null +++ b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx @@ -0,0 +1,217 @@ +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function SubscriptionBillingIssue() { + useScrollToHash(); + + return ( +
+ +

Subscription Billing Issue Event

+

+ A single cross-platform event that fires when a user's active + subscription enters a state that needs attention due to a payment + problem (card declined, expired payment method, billing retry, grace + period, etc.). +

+ +
+ + Platform behavior + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformSignal SourceDeliveryMinimum Version
iOS / iPadOS + StoreKit.Message.Reason.billingIssue + Push, while app is activeiOS 18.0+
Mac Catalyst + StoreKit.Message.Reason.billingIssue + Push, while app is activeMac Catalyst 18.0+
Android (Play) + Purchase.isSuspended + + Poll via getAvailablePurchases or on{' '} + onPurchasesUpdated + Play Billing Library 8.1+
Android (Meta Horizon)Not availableNever fires (silent no-op)N/A — Billing 7.0 compat SDK
macOS / tvOS / watchOS / visionOS + StoreKit.Message not available + Never firesN/A
+

+ Apple references:{' '} + + StoreKit.Message + + {' · '} + + Reason.billingIssue + + . Google reference:{' '} + + Suspended subscriptions + + . +

+
+ +
+ + Recommended UX + +

+ When this event fires, route the user to the platform subscription + center via deepLinkToSubscriptions() so they can update + their payment method. Do not re-grant entitlements on + the assumption the subscription is still active — Play suspends + entitlement for these purchases, and iOS will re-emit the message + until the billing issue is resolved. +

+
+ +
+ + Usage + + + {{ + typescript: ( + {`// react-native-iap OR expo-iap +import { + subscriptionBillingIssueListener, + deepLinkToSubscriptions, +} from 'react-native-iap'; + +const subscription = subscriptionBillingIssueListener((purchase) => { + console.warn('Subscription needs attention:', purchase.productId); + deepLinkToSubscriptions({ + skuAndroid: purchase.productId, + packageNameAndroid: 'com.example.app', + }); +}); + +// Cleanup +subscription.remove();`} + ), + dart: ( + {`final iap = FlutterInappPurchase.instance; + +final sub = iap.subscriptionBillingIssueListener.listen((purchase) { + debugPrint('Needs attention: \${purchase.productId}'); + iap.deepLinkToSubscriptions(options: DeepLinkOptions( + skuAndroid: purchase.productId, + packageNameAndroid: 'com.example.app', + )); +}); + +await sub.cancel();`} + ), + gdscript: ( + {`# godot-iap signal +var godot_iap := preload("res://addons/godot-iap/godot_iap.gd").new() +godot_iap.subscription_billing_issue.connect(func(purchase: Dictionary) -> void: + print("Needs attention: ", purchase["productId"]) + godot_iap.deep_link_to_subscriptions({ + "skuAndroid": purchase["productId"], + "packageNameAndroid": "com.example.app", + }) +)`} + ), + kmp: ( + {`import io.github.hyochan.kmpiap.kmpIapInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +kmpIapInstance.subscriptionBillingIssueListener + .onEach { purchase -> + println("Needs attention: \${purchase.productId}") + kmpIapInstance.deepLinkToSubscriptions( + DeepLinkOptions( + skuAndroid = purchase.productId, + packageNameAndroid = "com.example.app", + ), + ) + } + .launchIn(scope)`} + ), + }} + +
+ +
+ + Deduping + +

+ On Android, the native SDK tracks emitted purchase tokens per session + so the event fires once per affected purchase even if the app + polls getAvailablePurchases repeatedly. A new app + process, or a cleared billing issue that re-enters suspension, will + re-emit. +

+

+ On iOS the StoreKit Message may be re-delivered by the system until + the user resolves the underlying issue; for a given message the SDK + scans current entitlements and fires one event per subscription in{' '} + .inBillingRetryPeriod or .inGracePeriod. +

+
+
+ ); +} + +export default SubscriptionBillingIssue; diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 76cdaaf7..e3889318 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -30,6 +30,7 @@ import SubscriptionUpgradeDowngrade from './features/subscription/upgrade-downgr import Discount from './features/discount'; import OfferCodeRedemption from './features/offer-code-redemption'; import ExternalPurchase from './features/external-purchase'; +import SubscriptionBillingIssue from './features/subscription-billing-issue'; import AlternativeMarketplace from './features/alternative-marketplace/index'; import AlternativeMarketplaceOnside from './features/alternative-marketplace/onside'; import IOSSetup from './ios-setup'; @@ -272,6 +273,15 @@ function Docs() { External Purchase +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Subscription Billing Issue + +
  • } /> + } + /> } From 79c0a15b8acb63afd2dbbbec80b08b8c9c6112d5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 06:46:09 +0900 Subject: [PATCH 10/21] feat(examples,hook): onSubscriptionBillingIssue hook + example banners - 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) --- .../react-native-iap/src/hooks/useIAP.ts | 26 ++++++ .../Screens/SubscriptionFlowScreen.swift | 80 ++++++++++++++++- .../martie/screens/SubscriptionFlowScreen.kt | 88 +++++++++++++++++++ .../dev/hyo/openiap/store/OpenIapStore.kt | 2 + 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 4ae8af5a..4ca97f44 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -25,6 +25,7 @@ import { showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, userChoiceBillingListenerAndroid, + subscriptionBillingIssueListener, isStandardIOS, } from '../'; @@ -112,6 +113,16 @@ export interface UseIapOptions { onError?: (error: Error) => void; onPromotedProductIOS?: (product: Product) => void; onUserChoiceBillingAndroid?: (details: UserChoiceBillingDetails) => void; + /** + * Fires when an active subscription enters a billing-issue state + * (StoreKit 2 Message.billingIssue on iOS 18+, Purchase.isSuspended on + * Play Billing 8.1+). Not invoked on Meta Horizon. + * + * Recommended: call deepLinkToSubscriptions on the returned purchase so + * the user can update their payment method in the platform subscription + * center. + */ + onSubscriptionBillingIssue?: (purchase: Purchase) => void; /** * @deprecated Use enableBillingProgramAndroid instead. * - 'user-choice' → 'user-choice-billing' @@ -178,6 +189,7 @@ export function useIAP(options?: UseIapOptions): UseIap { purchaseError?: EventSubscription; promotedProductIOS?: EventSubscription; userChoiceBillingAndroid?: EventSubscription; + subscriptionBillingIssue?: EventSubscription; }>({}); // Track if component is mounted to prevent listener leaks on early unmount @@ -463,6 +475,18 @@ export function useIAP(options?: UseIapOptions): UseIap { } }); } + + if ( + optionsRef.current?.onSubscriptionBillingIssue && + !subscriptionsRef.current.subscriptionBillingIssue + ) { + subscriptionsRef.current.subscriptionBillingIssue = + subscriptionBillingIssueListener((purchase: Purchase) => { + if (optionsRef.current?.onSubscriptionBillingIssue) { + optionsRef.current.onSubscriptionBillingIssue(purchase); + } + }); + } }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]); // Shared helper: clean up all listeners @@ -471,10 +495,12 @@ export function useIAP(options?: UseIapOptions): UseIap { subscriptionsRef.current.purchaseError?.remove(); subscriptionsRef.current.promotedProductIOS?.remove(); subscriptionsRef.current.userChoiceBillingAndroid?.remove(); + subscriptionsRef.current.subscriptionBillingIssue?.remove(); subscriptionsRef.current.purchaseUpdate = undefined; subscriptionsRef.current.purchaseError = undefined; subscriptionsRef.current.promotedProductIOS = undefined; subscriptionsRef.current.userChoiceBillingAndroid = undefined; + subscriptionsRef.current.subscriptionBillingIssue = undefined; }, []); const initIapWithSubscriptions = useCallback(async (): Promise => { diff --git a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index 14cd5db5..028c5fdb 100644 --- a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -16,6 +16,10 @@ struct SubscriptionFlowScreen: View { @State private var isVerifying = false @State private var verificationResultMessage: String? @State private var processedPurchaseKey: String? + // Cross-platform subscriptionBillingIssue event (iOS 18+ StoreKit.Message.billingIssue). + // See: https://openiap.dev/docs/features/subscription-billing-issue + @State private var billingIssuePurchase: OpenIapPurchase? + @State private var billingIssueListenerToken: OpenIAP.Subscription? // IAPKit API Key from environment (set in scheme or Info.plist) private var iapkitApiKey: String? { @@ -66,6 +70,10 @@ struct SubscriptionFlowScreen: View { VerificationMethodCard() + if let issuePurchase = billingIssuePurchase { + billingIssueBanner(for: issuePurchase) + } + if isInitialLoading { LoadingCard(text: "Loading subscriptions...") } else if isRefreshing { @@ -226,7 +234,16 @@ struct SubscriptionFlowScreen: View { self.handlePurchaseError(error) } } - + + // Cross-platform subscriptionBillingIssue event + // Delivered via StoreKit.Message.billingIssue on iOS 18+ / Mac Catalyst 18+. + // Silent no-op on iOS 17 and earlier, and on macOS/tvOS/watchOS/visionOS. + billingIssueListenerToken = OpenIapModule.shared.subscriptionBillingIssueListener { purchase in + Task { @MainActor in + self.billingIssuePurchase = purchase + } + } + Task { do { try await iapStore.initConnection() @@ -246,6 +263,11 @@ struct SubscriptionFlowScreen: View { private func teardownConnection() { print("🔷 [SubscriptionFlow] Tearing down connection...") + if let token = billingIssueListenerToken { + OpenIapModule.shared.removeListener(token) + billingIssueListenerToken = nil + } + billingIssuePurchase = nil Task { try await iapStore.endConnection() print("✅ [SubscriptionFlow] Connection ended") @@ -795,6 +817,62 @@ struct SubscriptionFlowScreen: View { @available(iOS 15.0, *) @MainActor private extension SubscriptionFlowScreen { + /// UI banner for the cross-platform subscriptionBillingIssue event. + /// Fires on iOS 18+ when StoreKit delivers Message.Reason.billingIssue. + @ViewBuilder + func billingIssueBanner(for purchase: OpenIapPurchase) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.title3) + Text("Subscription needs attention") + .font(.headline) + Spacer() + } + Text("Payment on \(purchase.productId) failed or is in retry. Update your payment method in the subscription center to keep access.") + .font(.subheadline) + .foregroundColor(.secondary) + HStack(spacing: 8) { + Button { + Task { + do { + try await OpenIapModule.shared.deepLinkToSubscriptions(nil) + } catch { + print("⚠️ deepLinkToSubscriptions failed: \(error)") + } + } + } label: { + Label("Fix payment method", systemImage: "creditcard") + .font(.subheadline.bold()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.2)) + .foregroundColor(.orange) + .cornerRadius(8) + } + Button { + billingIssuePurchase = nil + } label: { + Text("Dismiss") + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundColor(.secondary) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.orange.opacity(0.4), lineWidth: 1) + ) + .cornerRadius(12) + .padding(.horizontal) + } + var subscriptionProductIds: [String] { var orderedIds: [String] = [] func appendIfNeeded(_ id: String) { diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index d5dfe780..3499df6c 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -144,6 +144,10 @@ fun SubscriptionFlowScreen( var verificationDropdownExpanded by remember { mutableStateOf(false) } // Track which purchase IDs have been processed (to allow re-purchase after failure) var processedPurchaseKey by remember { mutableStateOf(null) } + // Cross-platform subscriptionBillingIssue banner state. + // Populated from Play Billing 8.1+ isSuspended signal via + // openiap-google. See https://openiap.dev/docs/features/subscription-billing-issue + var billingIssuePurchase by remember { mutableStateOf(null) } // IAPKit API Key from BuildConfig val iapkitApiKey: String? = remember { @@ -251,6 +255,20 @@ fun SubscriptionFlowScreen( } } + // Cross-platform subscriptionBillingIssue listener: fires when Play Billing 8.1+ + // reports isSuspended == true on any active subscription. No-op on Horizon flavor. + DisposableEffect(iapStore) { + val listener = dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener { purchase -> + (purchase as? dev.hyo.openiap.PurchaseAndroid)?.let { android -> + billingIssuePurchase = android + } + } + iapStore.addSubscriptionBillingIssueListener(listener) + onDispose { + iapStore.removeSubscriptionBillingIssueListener(listener) + } + } + // Tick clock to update countdown once per second LaunchedEffect(Unit) { while (true) { @@ -415,6 +433,76 @@ fun SubscriptionFlowScreen( } } + // Subscription billing-issue banner (Play Billing 8.1+ isSuspended) + billingIssuePurchase?.let { issue -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = androidx.compose.ui.graphics.Color(0xFFFFF3E0) + ), + border = BorderStroke( + 1.dp, + androidx.compose.ui.graphics.Color(0xFFFF9800) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = androidx.compose.ui.graphics.Color(0xFFF57C00) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Subscription needs attention", + fontWeight = FontWeight.SemiBold + ) + } + Text( + "Payment on ${issue.productId} failed or is in retry. Update the payment method in the Play subscription center to keep access.", + color = AppColors.textSecondary + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { + cleanupScope.launch { + runCatching { + iapStore.deepLinkToSubscriptions( + dev.hyo.openiap.DeepLinkOptions( + skuAndroid = issue.productId, + packageNameAndroid = appContext.packageName + ) + ) + }.onFailure { e -> + println("deepLinkToSubscriptions failed: ${e.message}") + } + } + }) { + Icon( + imageVector = Icons.Filled.CreditCard, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Fix payment method") + } + TextButton(onClick = { billingIssuePurchase = null }) { + Text("Dismiss") + } + } + } + } + } + } + // Verification Method Card item { Card( diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index fb7ebfb5..4484cf9f 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -511,6 +511,8 @@ class OpenIapStore(private val module: OpenIapProtocol) { fun removeUserChoiceBillingListener(listener: dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener) = module.removeUserChoiceBillingListener(listener) fun addDeveloperProvidedBillingListener(listener: dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener) = module.addDeveloperProvidedBillingListener(listener) fun removeDeveloperProvidedBillingListener(listener: dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener) = module.removeDeveloperProvidedBillingListener(listener) + fun addSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) = module.addSubscriptionBillingIssueListener(listener) + fun removeSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) = module.removeSubscriptionBillingIssueListener(listener) // ------------------------------------------------------------------------- // Status helpers From 8633c3be8db3b6dde938256999135c1ac192f520 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 06:50:32 +0900 Subject: [PATCH 11/21] test,docs: Horizon no-op Robolectric test + sandbox E2E guide 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) --- knowledge/_claude-context/context.md | 136 +++++++++++++++++- .../sandbox-subscription-billing-issue.md | 128 +++++++++++++++++ packages/docs/public/llms-full.txt | 2 +- packages/docs/public/llms.txt | 2 +- packages/google/openiap/build.gradle.kts | 12 ++ ...SubscriptionBillingIssueHorizonNoOpTest.kt | 45 ++++++ 6 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 knowledge/internal/sandbox-subscription-billing-issue.md create mode 100644 packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 8c470e7f..10d09fe2 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-04-15T16:14:04.565Z +> Last updated: 2026-04-15T21:48:18.646Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -1528,6 +1528,140 @@ This file is automatically managed by CI/CD workflows during releases: Manual edits will cause version conflicts and deployment issues. Always use the GitHub Actions workflows to update versions. +--- + + + +--- +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: + - In **App Store Connect → Users and Access → Sandbox Testers**, open the tester and toggle **Billing Issue** to ON. Apple's docs: . + - Alternatively, use Sandbox Account → "Manage" → "Remove Payment Method" to simulate an expired card. +5. Relaunch the Example app (or send it to background then foreground). Within a few seconds StoreKit delivers `Message.Reason.billingIssue`. +6. 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 + ``` +- 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 / macOS / visionOS build — silent no-op by design (StoreKit.Message API is iOS-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: . +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 | + + --- # 📚 EXTERNAL API REFERENCE diff --git a/knowledge/internal/sandbox-subscription-billing-issue.md b/knowledge/internal/sandbox-subscription-billing-issue.md new file mode 100644 index 00000000..2262fb62 --- /dev/null +++ b/knowledge/internal/sandbox-subscription-billing-issue.md @@ -0,0 +1,128 @@ +--- +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: + - In **App Store Connect → Users and Access → Sandbox Testers**, open the tester and toggle **Billing Issue** to ON. Apple's docs: . + - Alternatively, use Sandbox Account → "Manage" → "Remove Payment Method" to simulate an expired card. +5. Relaunch the Example app (or send it to background then foreground). Within a few seconds StoreKit delivers `Message.Reason.billingIssue`. +6. 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 + ``` +- 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 / macOS / visionOS build — silent no-op by design (StoreKit.Message API is iOS-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: . +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 | diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 7928b99e..f09204b6 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-04-15T16:14:04.578Z +> Generated: 2026-04-15T21:48:18.659Z ## Table of Contents 1. Installation diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index c02a38ef..b33bf461 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-04-15T16:14:04.578Z +> Generated: 2026-04-15T21:48:18.659Z ## Installation diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 96f3d8d8..9ceb3b9d 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -77,6 +77,15 @@ android { named("testPlay") { java.srcDirs("src/testPlay/java") } + named("testHorizon") { + java.srcDirs("src/testHorizon/java") + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } } } @@ -117,6 +126,9 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") // Add Google Play Billing for tests (all flavors need it for OpenIapErrorTest) testImplementation("com.android.billingclient:billing-ktx:8.3.0") + // Robolectric for lightweight Android JVM tests (e.g. Horizon no-op listener) + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.test:core:1.5.0") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt new file mode 100644 index 00000000..1918ecae --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionBillingIssueHorizonNoOpTest.kt @@ -0,0 +1,45 @@ +package dev.hyo.openiap + +import androidx.test.core.app.ApplicationProvider +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicInteger + +/** + * Verifies that the Horizon flavor's OpenIapModule implements + * [OpenIapProtocol.addSubscriptionBillingIssueListener] and + * [OpenIapProtocol.removeSubscriptionBillingIssueListener] as **explicit + * no-ops** rather than throwing. + * + * Guards against accidentally letting Play-flavor emission logic leak into + * the Horizon flavor: the Horizon Billing Compatibility SDK targets Play + * Billing 7.0 which does not expose Purchase.isSuspended, so the listener + * must exist for API parity but must never invoke the callback. + * + * Reference: https://developers.meta.com/horizon/documentation/spatial-sdk/horizon-billing-compatibility-sdk/ + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [29]) +class SubscriptionBillingIssueHorizonNoOpTest { + + @Test + fun `add and remove SubscriptionBillingIssueListener never invokes the callback`() { + val context = ApplicationProvider.getApplicationContext() + val module = OpenIapModule(context) + val invoked = AtomicInteger(0) + val listener = OpenIapSubscriptionBillingIssueListener { invoked.incrementAndGet() } + + module.addSubscriptionBillingIssueListener(listener) + module.removeSubscriptionBillingIssueListener(listener) + + assertEquals( + "Horizon flavor must never invoke the subscriptionBillingIssue listener", + 0, + invoked.get() + ) + } +} From ca20da6a4125d7d6d05e0192fa25f239949d44db Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 07:08:59 +0900 Subject: [PATCH 12/21] fix(apple-example): unwrap iOS variant from Purchase in billing-issue 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) --- .../OpenIapExample/Screens/SubscriptionFlowScreen.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index 028c5fdb..da32ebaf 100644 --- a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -239,8 +239,9 @@ struct SubscriptionFlowScreen: View { // Delivered via StoreKit.Message.billingIssue on iOS 18+ / Mac Catalyst 18+. // Silent no-op on iOS 17 and earlier, and on macOS/tvOS/watchOS/visionOS. billingIssueListenerToken = OpenIapModule.shared.subscriptionBillingIssueListener { purchase in + guard let iosPurchase = purchase.asIOS() else { return } Task { @MainActor in - self.billingIssuePurchase = purchase + self.billingIssuePurchase = iosPurchase } } From 592308e8b5c37ac56fc67a6d9e5ded76e054e068 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 07:15:40 +0900 Subject: [PATCH 13/21] fix(review): address 24 PR 99 review threads (Copilot + CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- knowledge/_claude-context/context.md | 6 ++- knowledge/external/storekit2-api.md | 4 +- libraries/expo-iap/src/index.ts | 12 ++++- .../main/java/dev/hyo/godotiap/GodotIap.kt | 1 + .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 6 ++- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 21 +++++++-- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 2 + .../react-native-iap/ios/HybridRnIap.swift | 9 +++- .../react-native-iap/src/hooks/useIAP.ts | 11 ++--- libraries/react-native-iap/src/index.ts | 46 +++++++++++-------- .../Screens/SubscriptionFlowScreen.swift | 4 ++ packages/apple/Sources/OpenIapModule.swift | 9 ++-- packages/docs/public/llms-full.txt | 6 ++- packages/docs/public/llms.txt | 2 +- .../features/subscription-billing-issue.tsx | 2 +- .../docs/src/pages/docs/updates/releases.tsx | 7 +-- .../java/dev/hyo/openiap/OpenIapModule.kt | 4 ++ 17 files changed, 107 insertions(+), 45 deletions(-) diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 10d09fe2..f093d3c0 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-04-15T21:48:18.646Z +> Last updated: 2026-04-15T22:12:59.176Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -3549,7 +3549,9 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. -## External Purchase Support (iOS 18.2+) +## 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 diff --git a/knowledge/external/storekit2-api.md b/knowledge/external/storekit2-api.md index a0007c9a..19adf86b 100644 --- a/knowledge/external/storekit2-api.md +++ b/knowledge/external/storekit2-api.md @@ -362,7 +362,9 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. -## External Purchase Support (iOS 18.2+) +## 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 diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 806b8f14..efc040c7 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -319,7 +319,17 @@ export const developerProvidedBillingListenerAndroid = ( */ export const subscriptionBillingIssueListener = ( listener: (purchase: Purchase) => void, -) => emitter.addListener(OpenIapEvent.SubscriptionBillingIssue, listener); +) => { + // Mirror purchaseUpdatedListener's platform normalization so consumers get + // a consistent payload regardless of native casing. + const wrappedListener = (event: Purchase) => { + listener(normalizePurchasePlatform(event)); + }; + return emitter.addListener( + OpenIapEvent.SubscriptionBillingIssue, + wrappedListener, + ); +}; export const initConnection: MutationField<'initConnection'> = async (config) => ExpoIapModule.initConnection(config ?? null); diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index eb682369..b67e2764 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -111,6 +111,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { try { store.removePurchaseUpdateListener(purchaseUpdateListener) store.removePurchaseErrorListener(purchaseErrorListener) + openIap.removeSubscriptionBillingIssueListener(subscriptionBillingIssueListener) val result = store.endConnection() isInitialized = false diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index dabc853f..57b179ba 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -704,8 +704,10 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife } // Tracks purchase tokens already emitted as billing-issue events so we don't re-fire - // on every getAvailablePurchases call. - private val emittedBillingIssueTokens = mutableSetOf() + // on every getAvailablePurchases call. ConcurrentHashMap.newKeySet() keeps add() + // atomic under the Dispatchers.IO context used by getAvailablePurchasesHandler. + private val emittedBillingIssueTokens: MutableSet = + java.util.concurrent.ConcurrentHashMap.newKeySet() private fun notifySuspendedSubscriptions(purchases: List) { for (purchase in purchases) { diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 6bdb0241..e89d4dfa 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -45,8 +45,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // StoreKit 2 Message.billingIssue bridge (iOS 18+). // Reference: https://developer.apple.com/documentation/storekit/message/reason/4123328-billingissue - // Emission is driven from the Swift side via OpenIapModule.subscriptionBillingIssueListener; - // full cinterop bridge wiring lands in a follow-up release alongside the other downstream libs. + // Backed by openIapModule.addSubscriptionBillingIssueListener, set up in setupListeners() + // and removed in endConnection(). private val _subscriptionBillingIssueFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 16, @@ -69,6 +69,10 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } private fun setupListeners() { + // Idempotent: early-return if listeners already attached (e.g. init{} ran on + // construction and initConnection() tries to re-register). + if (purchaseSubscription != null) return + // Purchase updated listener purchaseSubscription = openIapModule.addPurchaseUpdatedListener { dictionary -> println("[KMP-IAP iOS] Purchase updated received: $dictionary") @@ -136,17 +140,28 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { continuation.resumeWithException(Exception(error.localizedDescription)) } else { isConnected = success + // Re-register listeners after endConnection()/initConnection() cycles. + // init{} runs only on first construction; without this, flows stop + // emitting after a disconnect + reconnect. + if (success) { + setupListeners() + } continuation.resume(success) } } } override suspend fun endConnection(): Boolean = suspendCoroutine { continuation -> - // Remove all listeners + // Remove all listeners and null the subscription tokens so initConnection() + // can freshly re-register without orphaning the previous subscriptions. purchaseSubscription?.let { openIapModule.removeListener(it) } + purchaseSubscription = null errorSubscription?.let { openIapModule.removeListener(it) } + errorSubscription = null promotedProductSubscription?.let { openIapModule.removeListener(it) } + promotedProductSubscription = null subscriptionBillingIssueSubscription?.let { openIapModule.removeListener(it) } + subscriptionBillingIssueSubscription = null openIapModule.endConnectionWithCompletion { success, error -> if (error != null) { diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index a41f3f18..300ae175 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -315,6 +315,8 @@ class HybridRnIap : HybridRnIapSpec() { promotedProductListenersIOS.clear() synchronized(userChoiceBillingListenersAndroid) { userChoiceBillingListenersAndroid.clear() } synchronized(developerProvidedBillingListenersAndroid) { developerProvidedBillingListenersAndroid.clear() } + synchronized(subscriptionBillingIssueListeners) { subscriptionBillingIssueListeners.clear() } + subscriptionBillingIssueAttached = false initDeferred = null RnIapLog.result("endConnection", true) true diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index a64847de..976192ed 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -930,8 +930,15 @@ class HybridRnIap: HybridRnIapSpec { attachSubscriptionBillingIssueSubIfNeeded() } + /// Removes the most recently-registered listener. Nitro callbacks don't expose + /// referential equality across bridge boundaries, so we pop the last entry to + /// mirror the Android behavior where the framework provides proper identity. func removeSubscriptionBillingIssueListener(listener: @escaping (NitroPurchase) -> Void) throws { - listenerLock.withLock { subscriptionBillingIssueListeners.removeAll() } + listenerLock.withLock { + if !subscriptionBillingIssueListeners.isEmpty { + subscriptionBillingIssueListeners.removeLast() + } + } } // MARK: - Private Helper Methods diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 4ca97f44..c127d918 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -476,15 +476,12 @@ export function useIAP(options?: UseIapOptions): UseIap { }); } - if ( - optionsRef.current?.onSubscriptionBillingIssue && - !subscriptionsRef.current.subscriptionBillingIssue - ) { + // Always attach so callers that supply `onSubscriptionBillingIssue` later + // (after the hook has already set up listeners) still receive events. + if (!subscriptionsRef.current.subscriptionBillingIssue) { subscriptionsRef.current.subscriptionBillingIssue = subscriptionBillingIssueListener((purchase: Purchase) => { - if (optionsRef.current?.onSubscriptionBillingIssue) { - optionsRef.current.onSubscriptionBillingIssue(purchase); - } + optionsRef.current?.onSubscriptionBillingIssue?.(purchase); }); } }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index e2cb2b73..82eed91e 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -599,6 +599,12 @@ const subscriptionBillingIssueJsListeners = new Set<(purchase: Purchase) => void let subscriptionBillingIssueNativeAttached = false; const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListener = (nitroPurchase) => { + if (!validateNitroPurchase(nitroPurchase)) { + RnIapConsole.warn( + '[subscriptionBillingIssueListener] dropped malformed native payload', + ); + return; + } const purchase = convertNitroPurchaseToPurchase(nitroPurchase); for (const listener of subscriptionBillingIssueJsListeners) { try { @@ -612,28 +618,32 @@ const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListen } }; +function tryAttachSubscriptionBillingIssueNative(): void { + if (subscriptionBillingIssueNativeAttached) return; + try { + IAP.instance.addSubscriptionBillingIssueListener( + subscriptionBillingIssueNativeHandler, + ); + subscriptionBillingIssueNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[subscriptionBillingIssueListener] Nitro not ready yet; will retry on next registration after initConnection()', + ); + } else { + throw e; + } + } +} + export const subscriptionBillingIssueListener = ( listener: (purchase: Purchase) => void, ): EventSubscription => { subscriptionBillingIssueJsListeners.add(listener); - - if (!subscriptionBillingIssueNativeAttached) { - try { - IAP.instance.addSubscriptionBillingIssueListener( - subscriptionBillingIssueNativeHandler, - ); - subscriptionBillingIssueNativeAttached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[subscriptionBillingIssueListener] Nitro not ready yet; listener inert until initConnection()', - ); - } else { - throw e; - } - } - } + // Retry attachment every call so a listener registered before initConnection() + // doesn't stay permanently inert once Nitro is ready. + tryAttachSubscriptionBillingIssueNative(); return { remove: () => { diff --git a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index da32ebaf..368cb90d 100644 --- a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -238,6 +238,10 @@ struct SubscriptionFlowScreen: View { // Cross-platform subscriptionBillingIssue event // Delivered via StoreKit.Message.billingIssue on iOS 18+ / Mac Catalyst 18+. // Silent no-op on iOS 17 and earlier, and on macOS/tvOS/watchOS/visionOS. + if let existing = billingIssueListenerToken { + OpenIapModule.shared.removeListener(existing) + billingIssueListenerToken = nil + } billingIssueListenerToken = OpenIapModule.shared.subscriptionBillingIssueListener { purchase in guard let iosPurchase = purchase.asIOS() else { return } Task { @MainActor in diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index aad6edef..da13d640 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -1365,6 +1365,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func cleanupExistingState() async { updateListenerTask?.cancel() updateListenerTask = nil + messageListenerTask?.cancel() + messageListenerTask = nil await state.reset() // iOS-only: Remove SKPaymentQueue observer for promoted in-app purchases // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases @@ -1525,9 +1527,10 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Starts the StoreKit 2 Message listener for subscription-billing-issue events. /// - /// `StoreKit.Message` ships on iOS, iPadOS and Mac Catalyst (16.0+). The `.billingIssue` - /// reason requires 18.0+. On macOS, tvOS, watchOS and visionOS the Message API is not - /// available, so this method is a silent no-op there. + /// The `.billingIssue` reason (what we care about) ships on iOS 18.0+ and Mac Catalyst + /// 18.0+, so this method only starts the `Message.messages` loop when that availability + /// holds. On macOS, tvOS, watchOS and visionOS the Message API is not available at all, + /// making this a silent no-op on those platforms. /// /// References: /// - https://developer.apple.com/documentation/storekit/message diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index f09204b6..559ca757 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-04-15T21:48:18.659Z +> Generated: 2026-04-15T22:12:59.189Z ## Table of Contents 1. Installation @@ -1923,7 +1923,9 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. -## External Purchase Support (iOS 18.2+) +## 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 diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index b33bf461..2335fea2 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-04-15T21:48:18.659Z +> Generated: 2026-04-15T22:12:59.189Z ## Installation diff --git a/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx index 19653e33..a3f4f519 100644 --- a/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx +++ b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx @@ -11,7 +11,7 @@ function SubscriptionBillingIssue() {
    diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index a12070ad..e5a9c88a 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -102,9 +102,10 @@ function Releases() {

    - Native bridge wiring for downstream libraries (react-native-iap, - expo-iap, flutter_inapp_purchase, godot-iap, kmp-iap) will land in - the next per-library release. + Native bridge wiring ships with this change across all downstream + libraries (react-native-iap, expo-iap, flutter_inapp_purchase, + godot-iap, kmp-iap). Each library picks it up as a minor version + bump through its usual release workflow.

    ), diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index bff108ab..de863037 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -179,6 +179,10 @@ class OpenIapModule( billingClient?.endConnection() productManager.clear() billingClient = null + // Reset subscription-billing-issue dedupe state so a fresh + // initConnection() can re-emit for previously-seen tokens. + emittedBillingIssueTokens.clear() + subscriptionBillingIssueListeners.clear() }.fold(onSuccess = { true }, onFailure = { false }) } } From 94c77a8e9392b2613fdd896a754cb30920ce6622 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 07:26:44 +0900 Subject: [PATCH 14/21] test(rn-iap): add addSubscriptionBillingIssueListener to jest mocks 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) --- .../react-native-iap/src/__tests__/hooks/useIAP.android.test.ts | 2 ++ libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts | 2 ++ libraries/react-native-iap/src/__tests__/index.test.ts | 2 ++ .../react-native-iap/src/__tests__/platform-detection.test.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts index 8bdde57a..e7f6dad4 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts @@ -16,6 +16,8 @@ const mockIap: any = { removePurchaseErrorListener: jest.fn(), addPromotedProductListenerIOS: jest.fn(), removePromotedProductListenerIOS: jest.fn(), + addSubscriptionBillingIssueListener: jest.fn(), + removeSubscriptionBillingIssueListener: jest.fn(), }; jest.mock('react-native-nitro-modules', () => ({ diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index e3380c3f..ddf330e7 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -16,6 +16,8 @@ const mockIap: any = { removePurchaseErrorListener: jest.fn(), addPromotedProductListenerIOS: jest.fn(), removePromotedProductListenerIOS: jest.fn(), + addSubscriptionBillingIssueListener: jest.fn(), + removeSubscriptionBillingIssueListener: jest.fn(), }; jest.mock('react-native-nitro-modules', () => ({ diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 8a2b6814..db8c8381 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -29,6 +29,8 @@ const mockIap: any = { removePurchaseErrorListener: jest.fn(), addPromotedProductListenerIOS: jest.fn(), removePromotedProductListenerIOS: jest.fn(), + addSubscriptionBillingIssueListener: jest.fn(), + removeSubscriptionBillingIssueListener: jest.fn(), // iOS-only getStorefrontIOS: jest.fn(async () => 'USA'), diff --git a/libraries/react-native-iap/src/__tests__/platform-detection.test.ts b/libraries/react-native-iap/src/__tests__/platform-detection.test.ts index 84969b7b..32aba396 100644 --- a/libraries/react-native-iap/src/__tests__/platform-detection.test.ts +++ b/libraries/react-native-iap/src/__tests__/platform-detection.test.ts @@ -21,6 +21,8 @@ const mockIap: any = { removePurchaseErrorListener: jest.fn(), addPromotedProductListenerIOS: jest.fn(), removePromotedProductListenerIOS: jest.fn(), + addSubscriptionBillingIssueListener: jest.fn(), + removeSubscriptionBillingIssueListener: jest.fn(), }; describe('Platform detection helpers', () => { From 75489341fa944520064084f32ecfddbf5c955cda Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Apr 2026 10:27:18 +0900 Subject: [PATCH 15/21] fix: address 4 codex review findings + tests + docs home expansion - [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) --- .../lib/flutter_inapp_purchase.dart | 2 + .../test/subscription_handlers_test.dart | 25 +++ .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 1 + .../SubscriptionBillingIssueDedupResetTest.kt | 51 +++++ .../react-native-iap/example/ios/Podfile | 20 ++ .../example/ios/add_test_target.rb | 70 +++++++ .../ios/example.xcodeproj/project.pbxproj | 174 ++++++++++++++++++ .../xcshareddata/xcschemes/example.xcscheme | 2 +- .../example/ios/exampleTests/Info.plist | 22 +++ ...bscriptionBillingIssueReconnectTests.swift | 97 ++++++++++ .../react-native-iap/ios/HybridRnIap.swift | 6 + packages/docs/src/pages/docs/events.tsx | 63 +++++++ packages/docs/src/pages/home.tsx | 56 ++++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 10 +- .../dev/hyo/openiap/helpers/CommonHelpers.kt | 19 ++ .../java/dev/hyo/openiap/OpenIapModule.kt | 9 +- ...criptionHandlersBillingIssueHorizonTest.kt | 45 +++++ .../SubscriptionHandlersBillingIssueTest.kt | 35 ++++ 18 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart create mode 100644 libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt create mode 100644 libraries/react-native-iap/example/ios/add_test_target.rb create mode 100644 libraries/react-native-iap/example/ios/exampleTests/Info.plist create mode 100644 libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift create mode 100644 packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueHorizonTest.kt create mode 100644 packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionHandlersBillingIssueTest.kt diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index e1f0e9af..f0c5f690 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -2310,6 +2310,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { purchaseError: () async => await purchaseErrorListener.first as gentype.PurchaseError, purchaseUpdated: () async => await purchaseUpdatedListener.first, + subscriptionBillingIssue: () async => + await subscriptionBillingIssueListener.first, userChoiceBillingAndroid: () async => await _userChoiceBillingAndroidListener.stream.first, developerProvidedBillingAndroid: () async => diff --git a/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart b/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart new file mode 100644 index 00000000..691c2be8 --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:flutter_inapp_purchase/types.dart' as types; + +/// Guards the Flutter `subscriptionHandlers` getter against regressing on +/// `subscriptionBillingIssue` bundle exposure. The stream +/// `subscriptionBillingIssueListener` already exists, but consumers using the +/// generated `SubscriptionHandlers` bundle get a null handler if the getter +/// forgets to wire it — this test catches that drift. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('subscriptionHandlers exposes subscriptionBillingIssue', () { + final iap = FlutterInappPurchase.instance; + final types.SubscriptionHandlers handlers = iap.subscriptionHandlers; + + expect( + handlers.subscriptionBillingIssue, + isNotNull, + reason: + 'subscriptionHandlers.subscriptionBillingIssue must be wired so ' + 'consumers using the generated handler bundle can await the event.', + ); + }); +} diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 57b179ba..fc94c8bd 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -261,6 +261,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife activityCallbacksDisposer = null _connectionStateListener.tryEmit(ConnectionResult(connected = false, message = "Disconnected")) clearProductCache(cachedProductDetails) + emittedBillingIssueTokens.clear() true }.getOrElse { false } } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt new file mode 100644 index 00000000..8911e932 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionBillingIssueDedupResetTest.kt @@ -0,0 +1,51 @@ +package io.github.hyochan.kmpiap + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Guards `InAppPurchaseAndroid` against a reconnect regression where the + * session-scoped `emittedBillingIssueTokens` dedupe set leaks across + * `endConnection()` calls. + * + * Without the reset, re-registering after a disconnect/reconnect cycle on + * the same instance would permanently suppress re-emission of any token + * that fired before the disconnect — users would stop seeing billing-issue + * events for subscriptions that transition back into suspension. + * + * We go through reflection because the set is deliberately private: the + * invariant under test is behavioral (reset-on-endConnection) and exposing + * the internal state just for a test would widen the API surface. + */ +class SubscriptionBillingIssueDedupResetTest { + + @Test + fun `endConnection clears emittedBillingIssueTokens so reconnect can re-emit`() = runTest { + val module = InAppPurchaseAndroid() + + val dedupField = InAppPurchaseAndroid::class.java + .getDeclaredField("emittedBillingIssueTokens") + .apply { isAccessible = true } + + @Suppress("UNCHECKED_CAST") + val dedup = dedupField.get(module) as MutableSet + dedup.add("token-before-disconnect") + assertTrue( + dedup.contains("token-before-disconnect"), + "precondition: dedup set should hold the seeded token" + ) + + module.endConnection() + + @Suppress("UNCHECKED_CAST") + val dedupAfter = dedupField.get(module) as Set + assertEquals( + emptySet(), + dedupAfter, + "endConnection() must clear the billing-issue dedupe set so " + + "the next session can re-emit tokens that fired before disconnect." + ) + } +} diff --git a/libraries/react-native-iap/example/ios/Podfile b/libraries/react-native-iap/example/ios/Podfile index f7cb0063..5371756f 100644 --- a/libraries/react-native-iap/example/ios/Podfile +++ b/libraries/react-native-iap/example/ios/Podfile @@ -58,6 +58,14 @@ target 'example' do pod 'openiap', openiap_apple_version end + # Host-app XCTest bundle. `inherit! :search_paths` lets the tests `@testable + # import NitroIap` (and reach OpenIAP + Nitro modules) without duplicating + # pod installations. The TEST_HOST runs inside the example app, so all + # linked frameworks are already loaded at test time. + target 'exampleTests' do + inherit! :search_paths + end + post_install do |installer| # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( @@ -76,6 +84,18 @@ target 'example' do # Allow non-modular includes within any pods that still ship framework-like headers config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES' end + + end + + # Xcode 26.x trips on consteval in fmt 11's headers. fmt detects compiler + # support unconditionally (no #ifndef guard), so a -D flag has no effect. + # Patch the header directly to force FMT_USE_CONSTEVAL=0. + fmt_base = File.join(__dir__, 'Pods/fmt/include/fmt/base.h') + 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') + File.write(fmt_base, patched) if patched != src end end diff --git a/libraries/react-native-iap/example/ios/add_test_target.rb b/libraries/react-native-iap/example/ios/add_test_target.rb new file mode 100644 index 00000000..7e3456a3 --- /dev/null +++ b/libraries/react-native-iap/example/ios/add_test_target.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# One-shot script to add a `exampleTests` XCTest unit test bundle target to +# example.xcodeproj. Run once from `libraries/react-native-iap/example/ios/`. +# Idempotent: exits early if the target already exists. + +require 'xcodeproj' + +PROJECT_PATH = 'example.xcodeproj' +TARGET_NAME = 'exampleTests' +TEST_FOLDER = 'exampleTests' + +project = Xcodeproj::Project.open(PROJECT_PATH) + +if project.targets.any? { |t| t.name == TARGET_NAME } + puts "Target '#{TARGET_NAME}' already exists; nothing to do." + exit 0 +end + +app_target = project.targets.find { |t| t.name == 'example' } +raise "Could not find 'example' app target" unless app_target + +# 1. Add a unit test bundle target +test_target = project.new_target( + :unit_test_bundle, + TARGET_NAME, + :ios, + app_target.build_configurations.first.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] || '15.1' +) + +# 2. Point at Info.plist, match app Swift version, set host application +app_bundle_id = app_target.build_configurations.first.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] +test_target.build_configurations.each do |config| + settings = config.build_settings + settings['PRODUCT_NAME'] = TARGET_NAME + settings['WRAPPER_EXTENSION'] = 'xctest' + settings['INFOPLIST_FILE'] = "#{TEST_FOLDER}/Info.plist" + settings['PRODUCT_BUNDLE_IDENTIFIER'] = "#{app_bundle_id}.Tests" + settings['SWIFT_VERSION'] = app_target.build_configurations.first.build_settings['SWIFT_VERSION'] || '5.0' + settings['TEST_HOST'] = "$(BUILT_PRODUCTS_DIR)/example.app/example" + settings['BUNDLE_LOADER'] = '$(TEST_HOST)' + settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' + settings['CLANG_ENABLE_MODULES'] = 'YES' + settings['ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES'] = 'NO' + settings['LD_RUNPATH_SEARCH_PATHS'] = [ + '$(inherited)', + '@executable_path/Frameworks', + '@loader_path/Frameworks' + ] +end + +# 3. Register source files +test_group = project.main_group.find_subpath(TEST_FOLDER, true) +test_group.set_source_tree('SOURCE_ROOT') + +Dir.glob("#{TEST_FOLDER}/*.swift").each do |file| + next if test_group.files.any? { |f| f.path == File.basename(file) } + file_ref = test_group.new_reference(File.basename(file)) + test_target.add_file_references([file_ref]) +end + +# 4. Depend on the app target so TEST_HOST is built first +test_target.add_dependency(app_target) + +# Scheme wiring is handled by Xcodeproj auto-generation on save. To run the +# tests from CLI: `xcodebuild test -scheme example -only-testing:exampleTests` +# after the developer opens the project once (or the scheme can be created via +# `xcodebuild -list` + manual scheme creation). + +project.save +puts "Added target '#{TARGET_NAME}' to #{PROJECT_PATH}." diff --git a/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj b/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj index 92932a6d..135d278b 100644 --- a/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj +++ b/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj @@ -9,21 +9,40 @@ /* Begin PBXBuildFile section */ 0C80B921A6F3F58F76C31292 /* libPods-example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 2C45A4A7038ED639A0DA8047 /* SubscriptionBillingIssueReconnectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F63961E1E4779D0D0CC89E /* SubscriptionBillingIssueReconnectTests.swift */; }; + 39B415FD9B823A99DF82885B /* libPods-exampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A1B53C93D4444C448EAF564 /* libPods-exampleTests.a */; }; 6CA9F570183E5756970ADA69 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 912AFAD7DD196565C20C2D28 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CB695629D08A69F09E048C9 /* Foundation.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 43FE2930222630099909FB87 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = example; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 13B07F961A680F5B00A75B9A /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = example/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = example/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = example/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 15F63961E1E4779D0D0CC89E /* SubscriptionBillingIssueReconnectTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SubscriptionBillingIssueReconnectTests.swift; sourceTree = ""; }; + 192B71D5D16C43463188CE4C /* exampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = exampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A1B53C93D4444C448EAF564 /* libPods-exampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-exampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.debug.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.release.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = example/AppDelegate.swift; sourceTree = ""; }; + 7A67B8342B38E57CDEFE6616 /* Pods-exampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-exampleTests.debug.xcconfig"; path = "Target Support Files/Pods-exampleTests/Pods-exampleTests.debug.xcconfig"; sourceTree = ""; }; + 7CB695629D08A69F09E048C9 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = example/LaunchScreen.storyboard; sourceTree = ""; }; + A414E86517B93E77B2E3EEA6 /* Pods-exampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-exampleTests.release.xcconfig"; path = "Target Support Files/Pods-exampleTests/Pods-exampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -36,6 +55,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33DF7049DA140D9D80197D59 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 912AFAD7DD196565C20C2D28 /* Foundation.framework in Frameworks */, + 39B415FD9B823A99DF82885B /* libPods-exampleTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -56,6 +84,8 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */, + FC984A179DE1CC6CAA91D019 /* iOS */, + 2A1B53C93D4444C448EAF564 /* libPods-exampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -75,6 +105,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, + 9BDCE38AE7FD2D25A10D850F /* exampleTests */, ); indentWidth = 2; sourceTree = ""; @@ -85,19 +116,38 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* example.app */, + 192B71D5D16C43463188CE4C /* exampleTests.xctest */, ); name = Products; sourceTree = ""; }; + 9BDCE38AE7FD2D25A10D850F /* exampleTests */ = { + isa = PBXGroup; + children = ( + 15F63961E1E4779D0D0CC89E /* SubscriptionBillingIssueReconnectTests.swift */, + ); + name = exampleTests; + sourceTree = SOURCE_ROOT; + }; BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */, 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */, + A414E86517B93E77B2E3EEA6 /* Pods-exampleTests.release.xcconfig */, + 7A67B8342B38E57CDEFE6616 /* Pods-exampleTests.debug.xcconfig */, ); path = Pods; sourceTree = ""; }; + FC984A179DE1CC6CAA91D019 /* iOS */ = { + isa = PBXGroup; + children = ( + 7CB695629D08A69F09E048C9 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -122,6 +172,25 @@ productReference = 13B07F961A680F5B00A75B9A /* example.app */; productType = "com.apple.product-type.application"; }; + 294D752163113EFA53480D2D /* exampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B5B56BEE63DAB47BA57E3B13 /* Build configuration list for PBXNativeTarget "exampleTests" */; + buildPhases = ( + 4CB1F9875334A4560ED2E9FC /* [CP] Check Pods Manifest.lock */, + F96F22CF7BEF4DB2FB25B07C /* Sources */, + 33DF7049DA140D9D80197D59 /* Frameworks */, + A7876AACA6EB7CE542CD1081 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B23914916F711C3412795261 /* PBXTargetDependency */, + ); + name = exampleTests; + productName = exampleTests; + productReference = 192B71D5D16C43463188CE4C /* exampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -149,6 +218,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* example */, + 294D752163113EFA53480D2D /* exampleTests */, ); }; /* End PBXProject section */ @@ -164,6 +234,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A7876AACA6EB7CE542CD1081 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -200,6 +277,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 4CB1F9875334A4560ED2E9FC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-exampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -250,8 +349,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F96F22CF7BEF4DB2FB25B07C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C45A4A7038ED639A0DA8047 /* SubscriptionBillingIssueReconnectTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + B23914916F711C3412795261 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = example; + target = 13B07F861A680F5B00A75B9A /* example */; + targetProxy = 43FE2930222630099909FB87 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -309,6 +425,31 @@ }; name = Release; }; + 4FBCBAB2B539ED28DDC345BB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A414E86517B93E77B2E3EEA6 /* Pods-exampleTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + INFOPLIST_FILE = exampleTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.hyo.martie.Tests; + PRODUCT_NAME = exampleTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/example"; + VALIDATE_PRODUCT = YES; + WRAPPER_EXTENSION = xctest; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -453,6 +594,30 @@ }; name = Release; }; + CA18B4749B617D9CF45D67F3 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A67B8342B38E57CDEFE6616 /* Pods-exampleTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + INFOPLIST_FILE = exampleTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.hyo.martie.Tests; + PRODUCT_NAME = exampleTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/example"; + WRAPPER_EXTENSION = xctest; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -474,6 +639,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B5B56BEE63DAB47BA57E3B13 /* Build configuration list for PBXNativeTarget "exampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FBCBAB2B539ED28DDC345BB /* Release */, + CA18B4749B617D9CF45D67F3 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; diff --git a/libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme b/libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme index fef9df78..abd0b86c 100644 --- a/libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme +++ b/libraries/react-native-iap/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme @@ -32,7 +32,7 @@ skipped = "NO"> diff --git a/libraries/react-native-iap/example/ios/exampleTests/Info.plist b/libraries/react-native-iap/example/ios/exampleTests/Info.plist new file mode 100644 index 00000000..6c40a6cd --- /dev/null +++ b/libraries/react-native-iap/example/ios/exampleTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift b/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift new file mode 100644 index 00000000..9b35d7c5 --- /dev/null +++ b/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift @@ -0,0 +1,97 @@ +import XCTest +import OpenIAP +@testable import NitroIap + +/// Reconnect regression coverage for the iOS subscriptionBillingIssue listener. +/// +/// The bug: `cleanupExistingState()` (invoked by `endConnection()`) used to leave +/// `subscriptionBillingIssueSub` non-nil and `subscriptionBillingIssueListeners` +/// non-empty, even though `OpenIapModule.shared.endConnection()` resets its +/// listener registry. On reconnect, `attachSubscriptionBillingIssueSubIfNeeded()` +/// would skip re-registration because the guard `subscriptionBillingIssueSub == nil` +/// was still false — and users would never see another billing-issue event after +/// a disconnect/reconnect cycle on the same HybridRnIap instance. +/// +/// Reflection note: the sub + listeners are `private` in HybridRnIap so `@testable` +/// alone cannot read them. `Mirror` ignores Swift access control at runtime, so we +/// use it to assert the post-conditions without widening the production API surface. +@available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *) +final class SubscriptionBillingIssueReconnectTests: XCTestCase { + + func testEndConnectionClearsBillingIssueSubAndListenersAndReconnectReRegisters() async throws { + let hybrid = HybridRnIap() + + // 1. Register a listener — attaches the OpenIAP subscription token. + try hybrid.addSubscriptionBillingIssueListener { _ in } + XCTAssertNotNil( + inspectSub(hybrid), + "addSubscriptionBillingIssueListener must attach subscriptionBillingIssueSub" + ) + XCTAssertEqual( + inspectListenerCount(hybrid), + 1, + "listener must be appended to subscriptionBillingIssueListeners" + ) + + // 2. endConnection → cleanupExistingState. This is the regression: the old + // implementation only reset the three original subs (purchaseUpdated, + // purchaseError, promotedProduct) and left the billing-issue slot dirty. + _ = try await hybrid.endConnection().await() + + XCTAssertNil( + inspectSub(hybrid), + "endConnection() must nil subscriptionBillingIssueSub so reconnect can re-register" + ) + XCTAssertEqual( + inspectListenerCount(hybrid), + 0, + "endConnection() must clear subscriptionBillingIssueListeners" + ) + + // 3. Reconnect: register again on the same instance. If step 2 failed to + // nil the sub, `attachSubscriptionBillingIssueSubIfNeeded()`'s guard + // `subscriptionBillingIssueSub == nil` would short-circuit and the + // OpenIAP listener registry would never see the new token. + try hybrid.addSubscriptionBillingIssueListener { _ in } + + XCTAssertNotNil( + inspectSub(hybrid), + "after endConnection + re-register, subscriptionBillingIssueSub must re-attach" + ) + XCTAssertEqual( + inspectListenerCount(hybrid), + 1, + "after re-register, subscriptionBillingIssueListeners must hold the new callback" + ) + } + + // MARK: - Private reflection helpers + + private func inspectSub(_ hybrid: HybridRnIap) -> Any? { + guard let child = childValue(hybrid, label: "subscriptionBillingIssueSub") else { + XCTFail("HybridRnIap no longer exposes subscriptionBillingIssueSub — test needs update") + return nil + } + let mirror = Mirror(reflecting: child) + // Optional.none surfaces as an Optional mirror with zero children. + if mirror.displayStyle == .optional { + return mirror.children.first?.value + } + return child + } + + private func inspectListenerCount(_ hybrid: HybridRnIap) -> Int { + guard let child = childValue(hybrid, label: "subscriptionBillingIssueListeners") else { + XCTFail("HybridRnIap no longer exposes subscriptionBillingIssueListeners — test needs update") + return -1 + } + return Mirror(reflecting: child).children.count + } + + private func childValue(_ object: Any, label: String) -> Any? { + Mirror(reflecting: object) + .children + .first { $0.label == label }? + .value + } +} diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 976192ed..0f9e1cd2 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -1160,9 +1160,14 @@ class HybridRnIap: HybridRnIapSpec { 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() @@ -1174,6 +1179,7 @@ class HybridRnIap: HybridRnIapSpec { purchaseUpdatedListeners.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() + subscriptionBillingIssueListeners.removeAll() lastPurchaseErrorKey = nil lastPurchaseErrorTimestamp = 0 deliveredPurchaseEventKeys.removeAll() diff --git a/packages/docs/src/pages/docs/events.tsx b/packages/docs/src/pages/docs/events.tsx index 4ebd3284..8c796a04 100644 --- a/packages/docs/src/pages/docs/events.tsx +++ b/packages/docs/src/pages/docs/events.tsx @@ -504,6 +504,69 @@ subscription.cancel();`} +
    + + Subscription Billing Issue Event + +

    + Fired when an active subscription enters a state that needs user + attention because of a payment problem — card declined, expired + payment method, billing retry, or grace period. Unifies StoreKit 2{' '} + Message.billingIssue (iOS 18+) and Play Billing{' '} + Purchase.isSuspended (Play Billing Library 8.1+) under a + single cross-platform stream. Silent no-op on platforms that cannot + emit (tvOS, watchOS, visionOS, macOS, Meta Horizon). +

    + +

    Listener Setup

    + + {{ + typescript: ( + {`subscriptionBillingIssueListener( + listener: (purchase: Purchase) => void +): Subscription`} + ), + swift: ( + {`// Callback + Subscription handle (iOS 18+ only) +func subscriptionBillingIssueListener( + _ listener: @escaping @Sendable (Purchase) -> Void +) -> Subscription`} + ), + kotlin: ( + {`// Callback approach (Play Billing 8.1+) +fun addSubscriptionBillingIssueListener( + listener: OpenIapSubscriptionBillingIssueListener +)`} + ), + kmp: ( + {`// Flow approach +val subscriptionBillingIssueListener: Flow`} + ), + dart: ( + {`Stream get subscriptionBillingIssueListener;`} + ), + gdscript: ( + {`signal subscription_billing_issue(purchase: Purchase)`} + ), + }} + +

    + The emitted Purchase is a regular subscription payload — + use productId, purchaseToken, and platform + fields to prompt the user to update payment. Play deduplicates by{' '} + purchaseToken per session; iOS fires per Message + delivery. +

    + +

    + See{' '} + + Subscription Billing Issue feature guide + {' '} + for platform coverage, signal sources, and UX recommendations. +

    +
    +