Priority: MANDATORY Each platform package has specific rules and workflows.
Before writing or editing anything, ALWAYS review:
The Types.swift file in Sources/Models/ is auto-generated from the OpenIAP GraphQL schema.
# Generate types using version from openiap-versions.json
./scripts/generate-types.sh
# Or override with environment variable
OPENIAP_GQL_VERSION=1.0.9 ./scripts/generate-types.shVersion is managed in openiap-versions.json:
{
"spec": "2.0.1",
"google": "2.1.3",
"apple": "2.1.6"
}To update GQL types:
- Edit
openiap-versions.json- change"gql"version - Run
./scripts/generate-types.sh - Run
swift testto verify compatibility
To bump Apple package version:
./scripts/bump-version.sh [major|minor|patch|x.x.x]swift test # Run tests
swift build # Build packageIMPORTANT: When updating iOS functions in OpenIapModule.swift, you MUST also update OpenIapModule+ObjC.swift.
The Objective-C bridge (OpenIapModule+ObjC.swift) exposes Swift async functions to Objective-C/Kotlin for:
- kmp-iap (Kotlin Multiplatform via cinterop)
- Any other platform that requires Objective-C interoperability
Update OpenIapModule+ObjC.swift when:
- Adding new public functions to
OpenIapModule.swift - Changing function signatures (parameters, return types)
- Adding new input options or parameters
- Changing existing function behavior
Every Swift async function needs an Objective-C completion handler wrapper:
// In OpenIapModule.swift (Swift async)
public func newFeatureIOS(param: String) async throws -> ResultType {
// implementation
}
// In OpenIapModule+ObjC.swift (ObjC bridge - MUST ADD)
@objc func newFeatureIOSWithParam(
_ param: String,
completion: @escaping (Any?, Error?) -> Void
) {
Task {
do {
let result = try await newFeatureIOS(param: param)
let dictionary = OpenIapSerialization.encode(result)
completion(dictionary, nil)
} catch {
completion(nil, error)
}
}
}| Swift Function Changed | ObjC Bridge Required |
|---|---|
OpenIapModule.swift |
OpenIapModule+ObjC.swift |
Verification: After updating, run:
swift build # Verifies ObjC bridge compilesWhen the GraphQL schema in packages/gql adds or changes an API, the regenerated types.* files declare the handler but do not implement it. Every wrapper library must wire the new API end-to-end or users will see silent nulls, phantom interfaces (GitHub issue #104), or UnsupportedOperationException at runtime.
The mechanical guardrail for this checklist is:
bun run audit:parityThis audit treats libraries/expo-iap/example as the non-Godot example SSOT
and fails when:
- a new non-Godot library appears under
libraries/without explicit parity coverage or exclusion - an Expo example route or product ID is not represented by the other SDK examples and native Apple/Google examples
- a GraphQL Query/Mutation/Subscription operation is added or removed without updating the operation parity registry
- generated types or shared TS runtime helpers drift from
packages/gql
Run it after type generation and before opening a PR for SDK/API/example
changes. If it fails for a newly introduced operation or feature, update the
missing SDK bridge/example/test coverage first, then update the parity registry
in scripts/audit-non-godot-parity.mjs.
A symptom like "interface exists in types.dart / types.ts / Types.kt but calling it does nothing / throws" means one or more of these layers is missing:
GraphQL schema ─► generated types ─► public API ─► native bridge ─► core module impl
(SSOT) (auto-generated) (hand-written) (hand-written) (shared Swift/Kotlin)
▲ ▲ ▲
│ │ │
must match must be exported must dispatch
For every new/changed handler in the generated types, verify all five of these per target library before considering the change shippable:
| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage |
|---|---|---|---|---|---|
| react-native-iap | src/types.ts (generated) |
src/index.ts export (Nitro or composed TS) |
ios/HybridRnIap.swift (iOS), android/.../HybridRnIap.kt (Android) |
Not required (flat exports) | Mock stub in all 4 mockIap objects in __tests__/ (per memory) |
| expo-iap | src/types.ts (generated) |
src/modules/ios.ts / android.ts export, re-exported from src/index.ts |
ios/ExpoIapModule.swift AsyncFunction, android/.../ExpoIapModule.kt |
Not required (flat exports) | src/modules/__tests__/*.test.ts |
| flutter_inapp_purchase | lib/types.dart (generated) |
getter on FlutterInappPurchase in lib/flutter_inapp_purchase.dart |
case "<name>": in ios/Classes/FlutterInappPurchasePlugin.swift, Android plugin onMethodCall |
queryHandlers / mutationHandlers / subscriptionHandlers bundles near the bottom of flutter_inapp_purchase.dart |
Mock + test in test/ios_methods_test.dart (and the errors_unit_test.dart error-mapping test) |
| kmp-iap | library/src/commonMain/.../openiap/Types.kt (generated interface) |
exposed via KmpInAppPurchase / kmpIapInstance |
library/src/iosMain/.../InAppPurchaseIOS.kt — must call openIapModule.<name>WithCompletion { ... }, never throw UnsupportedOperationException |
Not required (interface dispatch) | library/src/commonTest/ if testable cross-platform |
| godot-iap | addons/godot-iap/types.gd (generated) |
public snake_case function in addons/godot-iap/godot_iap.gd |
ios-gdextension/Sources/GodotIap/GodotIap.swift (iOS), android/src/main/java/.../GodotIap.java (Android) |
Not required | Manual testing — no automated test suite yet |
| maui-iap | src/OpenIap.Maui/Types.cs (generated) |
OpenIap.QueryResolver / MutationResolver interfaces in Types.cs; IOpenIap adds the listener-stream contract; static facade is OpenIap.Maui.Iap; IAPKit helpers mirror TypeScript via Iap.KitApi(...), Iap.ConnectWebhookStream(...), Iap.ParseWebhookEventData(...), and Iap.WebhookEventTypes |
Android: OpenIapMauiModule.kt in libraries/maui-iap/android/openiap/ (JSON-shaped Java facade over packages/google), bound by OpenIap.Maui.Bindings.Android.csproj, consumed by Platforms/Android/OpenIapAndroid.cs. iOS / macCatalyst: existing OpenIapModule+ObjC.swift bridge in packages/apple, bound by hand-written OpenIap.Maui.Bindings.iOS/ApiDefinition.cs, consumed by Platforms/iOS/OpenIapIOS.cs (+ subclass OpenIapMacCatalyst). |
Not required (interface dispatch) | Example app libraries/maui-iap/example/OpenIap.Maui.Example builds for net9.0-android / net9.0-ios / net9.0-maccatalyst (manual device testing for purchase flow); no xUnit tests yet |
The suffix on the handler name tells you which native bridges are required:
…IOSsuffix → iOS bridge only. Non-iOS platforms should return the type's zero value (false,null, empty list) or throw a documentedPlatformExceptionfor void ops. Do not wire into Android bridges.…Androidsuffix → Android bridge only. Same rule in reverse.- No suffix → both iOS and Android bridges required.
Wiring an iOS-suffixed method into an Android bridge is a bug — the earlier audit agents produced false positives like this.
- Phantom interface (GitHub issue #104, Flutter
beginRefundRequestIOSpre-2026-04): generated type exists, nothing else does. Users see an uncallable interface. UnsupportedOperationExceptionstub (KMP pattern): method declared, iOS impl deliberately throws with "not implemented in OpenIAP". Usually a stale stub — the ObjC bridge method may already exist. Alwaysgrep OpenIapModule+ObjC.swiftfor<name>With*before assuming the bridge is missing.- Channel-name drift (Flutter
getAppTransactionIOSpre-2026-04): Dart calls_channel.invokeMethod('getAppTransaction')but the Swift plugin only handles"getAppTransactionIOS"(or vice versa). Mocked tests passed because the test intercepted the wrong name too. - Handler bundle omission (Flutter): Dart getter exists, Swift bridge exists, but the new handler is not listed in
queryHandlers/mutationHandlers. Consumers using the generated handler bundle (e.g., for cross-platform dispatch) silently miss the API.
After regenerating types, run for each library:
# Replace <name> with the new handler name (camelCase, e.g., beginRefundRequestIOS)
NAME=<name>
echo "=== Type declared? ==="
rg -n "$NAME" \
libraries/*/lib/types.dart \
libraries/*/src/types.ts \
libraries/kmp-iap/library/src/commonMain/kotlin \
libraries/*/addons/godot-iap/types.gd
echo "=== Public API exposed? ==="
rg -n "^export (const|async function|function) $NAME\b|get $NAME\b|func $NAME\b|snake_case equivalent" libraries/
echo "=== Native bridge? ==="
rg -n "\"$NAME\"|\.$NAME\b" libraries/*/ios libraries/*/android libraries/*/ios-gdextension
echo "=== Wired into handlers bundle? (Flutter only) ==="
rg -n "$NAME:" libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
echo "=== Throws stub? ==="
rg -n "UnsupportedOperationException.*$NAME" libraries/Any empty result for a layer that should have the handler (per the suffix rule) is a gap that must be filled before merging.
Before writing or editing anything, ALWAYS review:
openiap/
├── src/
│ ├── main/ # Shared code (both flavors)
│ ├── play/ # Play Store specific code
│ └── horizon/ # Meta Horizon specific code
├── Example/ # Sample application
└── scripts/ # Automation
The Google package supports two build flavors:
| Flavor | Store | API | Description |
|---|---|---|---|
play (default) |
Google Play Store | Google Play Billing Library | Standard Android billing |
horizon |
Meta Quest Store | Meta Horizon API | VR/Quest billing |
Flavor-specific source directories:
src/main/- Shared code for both flavorssrc/play/- Play Store specific implementationssrc/horizon/- Meta Horizon specific implementations
- DO NOT edit generated files:
openiap/src/main/java/dev/hyo/openiap/Types.ktis auto-generated - Put reusable Kotlin helpers in
openiap/src/main/java/dev/hyo/openiap/utils/ - Run
./scripts/generate-types.shto regenerate types - Test BOTH flavors when making changes to shared code
# Play flavor (default)
./gradlew :openiap:compilePlayDebugKotlin
./gradlew :openiap:assemblePlayDebug
# Horizon flavor
./gradlew :openiap:compileHorizonDebugKotlin
./gradlew :openiap:assembleHorizonDebug
# Run tests (both flavors)
./gradlew :openiap:test| Flavor | Billing Library | Version |
|---|---|---|
| Play | Google Play Billing | 8.3.0 |
| Horizon | horizon-billing-compatibility | 1.1.1 (GPB 7.0 compatible) |
CRITICAL: Horizon SDK implements Billing 7.0 API, not 8.x. When writing shared code in src/main/:
Safe APIs (exist in both 7.0 and 8.x):
queryProductDetailsAsync(),launchBillingFlow()acknowledgePurchase(),consumeAsync(),queryPurchasesAsync()
DO NOT use in shared code (8.x only):
enableAutoServiceReconnection()- Product-level status codes
- One-time products with multiple offers
Meta Horizon has different APIs from Google Play:
| OpenIAP API | Play Implementation | Horizon Implementation |
|---|---|---|
verifyPurchase |
Play Developer API | Meta S2S verify_entitlement |
getAvailableItems |
N/A | Horizon catalog API |
IapStore |
IapStore.Play |
IapStore.Horizon |
Horizon-specific types in GraphQL:
VerifyPurchaseHorizonOptions- Horizon verification parametersVerifyPurchaseResultHorizon- Horizon verification result
- Edit
openiap-versions.jsonand update thegqlfield - Run
./scripts/generate-types.shto download and regenerate Types.kt - Compile BOTH flavors to verify:
./gradlew :openiap:compilePlayDebugKotlin ./gradlew :openiap:compileHorizonDebugKotlin
When: any change to
packages/googleorpackages/applethat 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.ktpackages/gql/src/error.graphql(ErrorCode enum additions — ripples through every generatedTypes.*)packages/apple/Sources/Models/OpenIapError.swiftpackages/apple/Sources/OpenIapModule.swift(public function signatures)
you must run the downstream compile for every framework library:
# 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 OpenIapTestsRight after changing OpenIapError.kt, run this grep to catch stale
singleton references that will fail in downstream compiles:
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.
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.
Before writing or editing anything, ALWAYS review:
The GQL package uses an IR-based (Intermediate Representation) code generation system:
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/*)
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)
│ └── csharp.ts # C# plugin (.NET MAUI)
└── templates/ # Handlebars templates (optional)
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 |
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 |
| C# | records, JsonConverter, [JsonPolymorphic] unions |
| Script | Description |
|---|---|
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:csharp |
Generate C# / MAUI types (IR-based plugin) |
generate |
Generate all types + sync to platforms |
sync |
Sync generated types to platform packages |
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
bun run generate:csharp| 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 |
src/generated/Types.cs |
.NET MAUI | C# records & JSON converters |
- Create
codegen/plugins/<language>.tsextendingCodegenPlugin - Implement abstract methods:
mapScalar()- Map GraphQL scalars to language typesmapType()- Map IR types to language type stringsgenerateEnum(),generateObject(), etc.
- Register in
codegen/index.ts - Add script to
package.json
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:
# => Union
type RequestPurchaseResult {
purchase: Purchase
purchases: [Purchase!]
}Before committing any changes:
- Run
npx prettier --writeto format all files - ALWAYS run
npm run lintto check for linting issues - ALWAYS run
bun run tscornpm run typecheckto check for TypeScript errors - Run
npm run buildto ensure no build errors
ANY function that returns a Promise must be wrapped with void operator when used where a void return is expected:
// CORRECT
<button onClick={() => void handleClick()}>Click</button>
<button onClick={() => void navigate("/path")}>Navigate</button>
<button onClick={() => void deleteThing({ id })}>Delete</button>
// INCORRECT - ESLint will flag these
<button onClick={handleClick}>Click</button>
<button onClick={() => navigate("/path")}>Go</button>