diff --git a/.claude/commands/sync-all-platforms.md b/.claude/commands/sync-all-platforms.md
new file mode 100644
index 00000000..9d481b09
--- /dev/null
+++ b/.claude/commands/sync-all-platforms.md
@@ -0,0 +1,461 @@
+# Sync OpenIAP Changes to All Platforms
+
+Master workflow to synchronize OpenIAP changes across all platform SDKs.
+
+## Environment Setup
+
+Set these environment variables before running sync commands:
+
+```bash
+# Add to your shell profile (.bashrc, .zshrc, etc.)
+export IAP_REPOS_HOME="/Users/hyo/Github/hyochan" # Parent directory of platform SDKs
+export OPENIAP_HOME="/Users/hyo/Github/hyodotdev" # Parent directory of openiap monorepo
+```
+
+## Target Repositories
+
+| Platform | Repository | Location |
+|----------|------------|----------|
+| Expo | expo-iap | `$IAP_REPOS_HOME/expo-iap` |
+| React Native | react-native-iap | `$IAP_REPOS_HOME/react-native-iap` |
+| Kotlin Multiplatform | kmp-iap | `$IAP_REPOS_HOME/kmp-iap` |
+| Godot | godot-iap | `$IAP_REPOS_HOME/godot-iap` |
+| Flutter | flutter_inapp_purchase | `$IAP_REPOS_HOME/flutter_inapp_purchase` |
+
+## Pre-Sync: Pull All Repositories (REQUIRED)
+
+**Always pull the latest code from all repositories before starting sync:**
+
+```bash
+# Pull all platform SDKs
+cd $IAP_REPOS_HOME/expo-iap && git pull
+cd $IAP_REPOS_HOME/react-native-iap && git pull
+cd $IAP_REPOS_HOME/kmp-iap && git pull
+cd $IAP_REPOS_HOME/godot-iap && git pull
+cd $IAP_REPOS_HOME/flutter_inapp_purchase && git pull
+
+# Return to openiap
+cd $OPENIAP_HOME/openiap
+```
+
+## Pre-Sync: Generate Types in OpenIAP
+
+```bash
+cd $OPENIAP_HOME/openiap/packages/gql
+
+# Generate all platform types
+bun run generate
+
+# Or generate specific platforms
+bun run generate:ts # TypeScript
+bun run generate:swift # Swift
+bun run generate:kotlin # Kotlin
+bun run generate:dart # Dart
+bun run generate:gdscript # GDScript
+```
+
+## Change Detection Checklist
+
+Before syncing, identify what changed:
+
+### 1. Type Changes (GraphQL Schema)
+- [ ] New types added
+- [ ] Existing types modified
+- [ ] Types deprecated/removed
+- [ ] New enums/enum values
+- [ ] Field name changes
+- [ ] Optional/required field changes
+
+### 2. API Changes
+- [ ] New functions added
+- [ ] Function signatures changed
+- [ ] Functions deprecated
+- [ ] Error codes changed
+
+### 3. Native SDK Changes
+- [ ] iOS SDK (`packages/apple`) updated
+- [ ] Android SDK (`packages/google`) updated
+- [ ] Breaking changes in native APIs
+
+---
+
+## Platform-Specific Sync
+
+### 1. expo-iap
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# Update versions
+# Edit openiap-versions.json: gql, apple, google
+
+# Sync types
+bun run generate:types
+
+# Native iOS
+cd ios
+# Review changes needed in Swift code
+pod install
+
+# Native Android
+# Review android/src/main/java/ for needed updates
+
+# Build & Test
+bun run typecheck
+bun run lint
+bun run test
+cd example && bun run test
+
+# Build example apps
+cd example
+npx expo prebuild --clean
+cd ios && pod install
+npx expo run:ios
+npx expo run:android
+```
+
+### 2. react-native-iap
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# Update versions
+# Edit openiap-versions.json: gql, apple, google
+
+# Sync types
+yarn generate:types
+
+# Native iOS (ios/HybridRnIap.swift)
+# - Update Swift implementation for new APIs
+# - Handle new types in type conversion
+
+# Native Android (android/.../HybridRnIap.kt)
+# - Update Kotlin implementation for new APIs
+# - Handle new types in type conversion
+
+# Regenerate Nitro bridge if needed
+yarn specs
+
+# Build & Test
+yarn typecheck
+yarn lint
+yarn test
+
+# Build example apps
+yarn workspace rn-iap-example ios
+yarn workspace rn-iap-example android
+
+# Expo example
+cd example-expo
+bun setup
+bun ios
+bun android
+```
+
+### 3. kmp-iap
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# Update versions
+# Edit openiap-versions.json: gql, google, apple
+
+# Sync types
+./scripts/generate-types.sh
+
+# Common code updates (library/src/commonMain/)
+# - Update KmpIap.kt type aliases for new types
+# - Update dsl/PurchaseDsl.kt for new request builders
+# - Update utils/ErrorMapping.kt for new error codes
+
+# Native Android (library/src/androidMain/)
+# - Update InAppPurchaseAndroid.kt for new APIs
+# - Update Helper.kt for new conversions
+
+# Native iOS (library/src/iosMain/)
+# - Update InAppPurchaseIOS.kt for new APIs
+# - C-Interop updates if Swift API changed
+
+# Build & Test
+./gradlew :library:test
+./gradlew :library:build
+./gradlew :library:detekt
+
+# Build example
+./gradlew :example:composeApp:assembleDebug
+
+# iOS build test
+cd example/iosApp
+pod install
+xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator
+```
+
+### 4. godot-iap
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+
+# Types are generated in openiap
+# Copy from openiap
+cp $OPENIAP_HOME/openiap/packages/gql/src/generated/types.gd \
+ addons/openiap/types.gd
+
+# Update implementation
+# - addons/openiap/iap.gd for new APIs
+# - addons/openiap/store.gd for store changes
+
+# Update openiap-versions.json
+
+# Test in Godot Editor
+# Open project in Godot 4.x
+# Run test scenes
+# Check for GDScript errors
+
+# Run GDUnit4 tests if available
+godot --headless -s addons/gdunit4/test_runner.gd
+```
+
+### 5. flutter_inapp_purchase
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Update versions
+# Edit openiap-versions.json: gql, apple, google
+
+# Sync types
+./scripts/generate-type.sh
+
+# Update Dart implementation
+# - lib/flutter_inapp_purchase.dart for new APIs
+# - lib/helpers.dart for type conversions
+# - lib/errors.dart for error codes
+# - lib/builders.dart for new request builders
+
+# Native iOS (ios/Classes/FlutterInappPurchasePlugin.swift)
+# - Update Swift implementation
+# - Handle new method channels
+
+# Native Android (android/.../FlutterInappPurchasePlugin.kt)
+# - Update Kotlin implementation
+# - Handle new method channels
+
+# Build & Test
+flutter analyze
+flutter test
+
+# Build example
+cd example
+flutter build ios --no-codesign
+flutter build apk
+
+# Run example
+flutter run -d ios
+flutter run -d android
+```
+
+---
+
+## Native Code Modification Checklist
+
+### iOS Native Code Updates
+
+For each platform SDK, check:
+
+1. **New API Methods**
+ - [ ] Method signature matches OpenIAP spec
+ - [ ] Return types correctly mapped
+ - [ ] Error handling follows pattern
+ - [ ] Async/await properly used (StoreKit 2)
+
+2. **Type Conversions**
+ - [ ] New types have conversion functions
+ - [ ] Optional fields handled correctly
+ - [ ] Platform-specific fields mapped
+
+3. **Error Handling**
+ - [ ] New error codes mapped
+ - [ ] Error messages localized if needed
+ - [ ] Proper error propagation
+
+### Android Native Code Updates
+
+1. **New API Methods**
+ - [ ] Method signature matches OpenIAP spec
+ - [ ] Coroutines/suspend functions properly used
+ - [ ] BillingClient callbacks handled
+
+2. **Type Conversions**
+ - [ ] New types have conversion functions
+ - [ ] Nullable fields handled correctly
+ - [ ] Platform-specific fields mapped
+
+3. **Error Handling**
+ - [ ] BillingResponseCode mapping updated
+ - [ ] Error messages consistent
+
+---
+
+## Build Test Matrix
+
+| Platform | iOS Build | Android Build | Horizon Build | Tests | Example |
+|----------|-----------|---------------|---------------|-------|---------|
+| expo-iap | `npx expo run:ios` | `npx expo run:android` | See Horizon section | `bun run test` | `example/` |
+| react-native-iap | `yarn workspace ... ios` | `yarn workspace ... android` | See Horizon section | `yarn test` | `example/` |
+| kmp-iap | `xcodebuild` | `./gradlew assembleDebug` | N/A | `./gradlew test` | `example/` |
+| godot-iap | Xcode export | Gradle export | N/A | GDUnit4 | `examples/` |
+| flutter_inapp_purchase | `flutter build ios` | `flutter build apk` | See Horizon section | `flutter test` | `example/` |
+
+### Android Horizon Build (Meta Quest)
+
+OpenIAP supports Meta Horizon Store via build flavors. When syncing, verify Horizon builds work:
+
+#### openiap (packages/google)
+
+```bash
+cd $OPENIAP_HOME/openiap/packages/google
+
+# Build Horizon flavor
+./gradlew :openiap:assembleHorizonDebug
+./gradlew :openiap:assembleHorizonRelease
+
+# Run Horizon tests
+./gradlew :openiap:testHorizonDebugUnitTest
+```
+
+#### expo-iap
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap/example
+
+# Enable Horizon in gradle.properties
+echo "horizonEnabled=true" >> android/gradle.properties
+
+# Build with Horizon
+npx expo run:android
+
+# Remember to revert for Play Store builds
+sed -i '' '/horizonEnabled=true/d' android/gradle.properties
+```
+
+#### react-native-iap
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap/example
+
+# Enable Horizon in gradle.properties
+echo "horizonEnabled=true" >> android/gradle.properties
+
+# Build with Horizon
+yarn android
+
+# Remember to revert for Play Store builds
+sed -i '' '/horizonEnabled=true/d' android/gradle.properties
+```
+
+#### flutter_inapp_purchase
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase/example
+
+# Build with Horizon flavor
+flutter build apk --flavor horizon
+
+# Or run with Horizon
+flutter run --flavor horizon
+```
+
+---
+
+## Documentation Updates
+
+After code changes, update documentation in each repo:
+
+1. **API Reference** - New/changed methods
+2. **Type Definitions** - New/changed types
+3. **Migration Guide** - Breaking changes
+4. **Examples** - Updated usage patterns
+5. **CHANGELOG** - Version history
+6. **llms.txt Files** - AI-friendly documentation
+
+### llms.txt Update Locations
+
+| Platform | llms.txt Location |
+|----------|-------------------|
+| expo-iap | `docs/static/llms.txt`, `docs/static/llms-full.txt` |
+| react-native-iap | `docs/static/llms.txt`, `docs/static/llms-full.txt` |
+| kmp-iap | `docs/static/llms.txt`, `docs/static/llms-full.txt` |
+| godot-iap | `docs/static/llms.txt`, `docs/static/llms-full.txt` |
+| flutter_inapp_purchase | `docs/static/llms.txt`, `docs/static/llms-full.txt` |
+| openiap (docs) | `packages/docs/public/llms.txt`, `packages/docs/public/llms-full.txt` |
+
+**When to update llms.txt:**
+
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- Usage patterns updated
+- Error codes changed
+- Platform-specific APIs added
+
+---
+
+## Deprecation Handling
+
+When deprecating APIs:
+
+1. **Mark deprecated in types** - Add `@deprecated` annotations
+2. **Add migration notes** - Document replacement API
+3. **Update examples** - Remove deprecated usage
+4. **Log warnings** - Runtime deprecation warnings
+5. **Set removal timeline** - Specify when fully removed
+
+---
+
+## Commit Strategy
+
+For each platform:
+
+```bash
+# Feature addition
+git add .
+git commit -m "feat: sync with openiap v1.x.x - add discount offers"
+
+# Breaking change
+git commit -m "feat!: sync with openiap v1.x.x - update purchase API
+
+BREAKING CHANGE: requestPurchase now requires DiscountOfferInput for iOS"
+
+# Documentation only
+git commit -m "docs: sync with openiap v1.x.x - update API docs"
+```
+
+---
+
+## Post-Sync Verification
+
+1. **All platforms build successfully**
+2. **All tests pass**
+3. **Example apps run on simulators/devices**
+4. **Documentation is updated**
+5. **No deprecated API usage in examples**
+6. **Version numbers updated**
+
+## Quick Reference Commands
+
+```bash
+# expo-iap
+cd $IAP_REPOS_HOME/expo-iap && bun run generate:types && bun run test
+
+# react-native-iap
+cd $IAP_REPOS_HOME/react-native-iap && yarn generate:types && yarn test
+
+# kmp-iap
+cd $IAP_REPOS_HOME/kmp-iap && ./scripts/generate-types.sh && ./gradlew :library:test
+
+# godot-iap
+cp $OPENIAP_HOME/openiap/packages/gql/src/generated/types.gd $IAP_REPOS_HOME/godot-iap/addons/openiap/types.gd
+
+# flutter_inapp_purchase
+cd $IAP_REPOS_HOME/flutter_inapp_purchase && ./scripts/generate-type.sh && flutter test
+```
diff --git a/.claude/commands/sync-expo-iap.md b/.claude/commands/sync-expo-iap.md
new file mode 100644
index 00000000..7ef128d8
--- /dev/null
+++ b/.claude/commands/sync-expo-iap.md
@@ -0,0 +1,296 @@
+# Sync Changes to expo-iap
+
+Synchronize OpenIAP changes to the [expo-iap](https://github.com/hyochan/expo-iap) repository.
+
+**Target Repository:** `$IAP_REPOS_HOME/expo-iap`
+
+> **Note:** Set `IAP_REPOS_HOME` environment variable (see [sync-all-platforms.md](./sync-all-platforms.md#environment-setup))
+
+## Project Overview
+
+- **Package Manager:** Bun
+- **Framework:** Expo Module (React Native)
+- **Current Version:** Check `package.json`
+- **OpenIAP Version Tracking:** `openiap-versions.json`
+
+## Key Files
+
+| File | Purpose | Auto-Generated |
+|------|---------|----------------|
+| `src/types.ts` | TypeScript types from OpenIAP | YES |
+| `src/index.ts` | Main API exports | NO |
+| `src/useIAP.ts` | React Hook for IAP | NO |
+| `src/modules/ios.ts` | iOS-specific functions | NO |
+| `src/modules/android.ts` | Android-specific functions | NO |
+| `openiap-versions.json` | Version tracking | NO |
+
+## Sync Steps
+
+### 0. Pull Latest (REQUIRED)
+
+**Always pull the latest code before starting any sync work:**
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+git pull
+```
+
+### 1. Sync openiap-versions.json (REQUIRED)
+
+**IMPORTANT:** Before generating types, sync version numbers from openiap monorepo.
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# Check current versions in openiap monorepo
+cat $OPENIAP_HOME/openiap/openiap-versions.json
+
+# Update expo-iap's openiap-versions.json to match:
+# - "gql": should match openiap's "gql" version
+# - "apple": should match openiap's "apple" version
+# - "google": should match openiap's "google" version
+```
+
+**Version fields to sync:**
+| Field | Source | Purpose |
+|-------|--------|---------|
+| `gql` | `$OPENIAP_HOME/openiap/openiap-versions.json` | TypeScript types version |
+| `apple` | `$OPENIAP_HOME/openiap/openiap-versions.json` | iOS native SDK version |
+| `google` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Android native SDK version |
+
+### 2. Type Synchronization
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# Download and regenerate types (uses versions from openiap-versions.json)
+bun run generate:types
+
+# Verify types
+bun run typecheck
+```
+
+### 3. Native Code Modifications
+
+#### iOS Native Code
+
+**Location:** `ios/`
+
+Key files to update:
+- `ios/ExpoIapModule.swift` - Main Expo module implementation
+- `ios/ExpoIap.podspec` - CocoaPods spec (update `apple` version dependency)
+
+**When to modify:**
+- New iOS-specific API methods added to OpenIAP
+- Type conversion changes needed
+- StoreKit 2 API changes
+
+**Update workflow:**
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# 1. Update apple version in openiap-versions.json
+# 2. Review openiap/packages/apple/Sources/ for changes
+# 3. Update ios/ExpoIapModule.swift accordingly
+
+# Install updated pod
+cd example/ios && pod install --repo-update
+```
+
+#### Android Native Code
+
+**Location:** `android/src/main/java/`
+
+Key files to update:
+- `ExpoIapModule.kt` - Main Expo module implementation
+- `build.gradle` - Dependencies (auto-reads `google` version)
+
+**When to modify:**
+- New Android-specific API methods added to OpenIAP
+- Type conversion changes needed
+- Play Billing API changes
+
+**Update workflow:**
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# 1. Update google version in openiap-versions.json
+# 2. Review openiap/packages/google/openiap/src/main/ for changes
+# 3. Update android/src/main/java/ accordingly
+
+# Gradle auto-syncs on build
+```
+
+### 4. Build & Test Native Code
+
+#### iOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap/example
+
+# Clean and prebuild
+npx expo prebuild --clean --platform ios
+
+# Install pods
+cd ios && pod install --repo-update && cd ..
+
+# Build for simulator
+npx expo run:ios --device "iPhone 15 Pro"
+
+# Or build via Xcode
+open ios/expoiapexample.xcworkspace
+# Build: Cmd+B, Run: Cmd+R
+```
+
+#### Android Build Test
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap/example
+
+# Clean and prebuild
+npx expo prebuild --clean --platform android
+
+# Build debug APK
+npx expo run:android
+
+# Or build via Android Studio
+# Open android/ folder in Android Studio
+# Build > Make Project
+```
+
+#### Android Horizon Build (Meta Quest)
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap/example
+
+# Enable Horizon flavor in gradle.properties
+echo "horizonEnabled=true" >> android/gradle.properties
+
+# Prebuild and build with Horizon
+npx expo prebuild --clean --platform android
+npx expo run:android
+
+# Revert for Play Store builds
+sed -i '' '/horizonEnabled=true/d' android/gradle.properties
+```
+
+#### Full Build Matrix
+
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+
+# TypeScript build
+bun run build
+
+# iOS build
+cd example && npx expo run:ios
+
+# Android build (Play Store)
+cd example && npx expo run:android
+
+# Android build (Horizon)
+cd example && echo "horizonEnabled=true" >> android/gradle.properties && npx expo run:android
+
+# All tests
+bun run test
+cd example && bun run test
+```
+
+### 5. Update Example Code
+
+**Location:** `example/app/`
+
+Key example screens:
+- `index.tsx` - Home/Overview
+- `purchase-flow.tsx` - Purchase flow demo
+- `subscription-flow.tsx` - Subscription demo
+- `alternative-billing.tsx` - Android alt billing
+- `offer-code.tsx` - Promo code redemption
+
+### 6. Update Tests
+
+**Library Tests:** `src/__tests__/`
+**Example Tests:** `example/__tests__/`
+
+```bash
+# Run all tests
+bun run test
+
+# Run example tests
+cd example && bun run test
+```
+
+### 7. Update Documentation
+
+**Location:** `docs/`
+- `docs/api/` - API reference
+- `docs/guides/` - Usage guides
+- `docs/examples/` - Code examples
+
+### 8. Update llms.txt Files
+
+**Location:** `docs/static/`
+
+Update AI-friendly documentation files when APIs or types change:
+
+- `docs/static/llms.txt` - Quick reference for AI assistants
+- `docs/static/llms-full.txt` - Detailed AI reference
+
+**When to update:**
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- Usage patterns updated
+- Error codes changed
+
+**Content to sync:**
+1. Installation commands
+2. Core API reference (useIAP hook, direct functions)
+3. Key types (Product, Purchase, ErrorCode)
+4. Common usage patterns
+5. Platform-specific APIs (iOS/Android suffixes)
+6. Error handling examples
+
+### 9. Pre-commit Checklist
+
+```bash
+bun run lint # ESLint
+bun run typecheck # TypeScript
+bun run test # Jest
+cd example && bun run test # Example app tests
+```
+
+## Naming Conventions
+
+- **iOS-only:** `functionNameIOS` (e.g., `syncIOS`, `getPromotedProductIOS`)
+- **Android-only:** `functionNameAndroid` (e.g., `validateReceiptAndroid`)
+- **Cross-platform:** No suffix (e.g., `fetchProducts`, `requestPurchase`)
+- **Error codes:** kebab-case (e.g., `'user-cancelled'`)
+
+## Deprecation Check
+
+Search for deprecated patterns:
+```bash
+cd $IAP_REPOS_HOME/expo-iap
+grep -r "@deprecated" src/
+grep -r "DEPRECATED" src/
+```
+
+Known deprecated functions:
+- `requestProducts` -> Use `fetchProducts`
+- `validateReceipt` -> Use `verifyPurchase`
+- `validateReceiptIOS` -> Use `verifyPurchase`
+
+## Commit Message Format
+
+```text
+feat: add discount offer support
+fix: resolve iOS purchase verification
+docs: update subscription flow guide
+```
+
+## References
+
+- **CLAUDE.md:** `$IAP_REPOS_HOME/expo-iap/CLAUDE.md`
+- **OpenIAP Docs:** [openiap.dev/docs](https://openiap.dev/docs)
+- **expo-iap Docs:** [expo-iap.vercel.app](https://expo-iap.vercel.app)
diff --git a/.claude/commands/sync-flutter-iap.md b/.claude/commands/sync-flutter-iap.md
new file mode 100644
index 00000000..d249c322
--- /dev/null
+++ b/.claude/commands/sync-flutter-iap.md
@@ -0,0 +1,381 @@
+# Sync Changes to flutter_inapp_purchase
+
+Synchronize OpenIAP changes to the [flutter_inapp_purchase](https://github.com/hyochan/flutter_inapp_purchase) repository.
+
+**Target Repository:** `$IAP_REPOS_HOME/flutter_inapp_purchase`
+
+> **Note:** Set `IAP_REPOS_HOME` environment variable (see [sync-all-platforms.md](./sync-all-platforms.md#environment-setup))
+
+## Project Overview
+
+- **Framework:** Flutter Plugin
+- **Language:** Dart
+- **Platforms:** iOS, Android, macOS
+- **Current Version:** Check `pubspec.yaml`
+- **OpenIAP Version Tracking:** `openiap-versions.json`
+
+## Key Files
+
+| File | Purpose | Auto-Generated |
+|------|---------|----------------|
+| `lib/types.dart` | Dart types from OpenIAP | YES |
+| `lib/flutter_inapp_purchase.dart` | Main API class | NO |
+| `lib/helpers.dart` | Type conversion utilities | NO |
+| `lib/errors.dart` | Error handling & codes | NO |
+| `lib/builders.dart` | Request builder DSL | NO |
+| `lib/enums.dart` | Custom enums | NO |
+| `ios/Classes/FlutterInappPurchasePlugin.swift` | iOS implementation | NO |
+| `android/.../FlutterInappPurchasePlugin.kt` | Android implementation | NO |
+| `openiap-versions.json` | Version tracking | NO |
+
+## Version File Structure
+
+```json
+{
+ "apple": "1.3.5",
+ "google": "1.3.14",
+ "gql": "1.3.5"
+}
+```
+
+## Sync Steps
+
+### 0. Pull Latest (REQUIRED)
+
+**Always pull the latest code before starting any sync work:**
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+git pull
+```
+
+### 1. Sync openiap-versions.json (REQUIRED)
+
+**IMPORTANT:** Before generating types, sync version numbers from openiap monorepo.
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Check current versions in openiap monorepo
+cat $OPENIAP_HOME/openiap/openiap-versions.json
+
+# Update flutter_inapp_purchase's openiap-versions.json to match:
+# - "gql": should match openiap's "gql" version
+# - "apple": should match openiap's "apple" version
+# - "google": should match openiap's "google" version
+```
+
+**Version fields to sync:**
+| Field | Source | Purpose |
+|-------|--------|---------|
+| `gql` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Dart types version |
+| `apple` | `$OPENIAP_HOME/openiap/openiap-versions.json` | iOS native SDK version |
+| `google` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Android native SDK version |
+
+### 2. Type Synchronization
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Download and regenerate types (uses versions from openiap-versions.json)
+./scripts/generate-type.sh
+
+# Verify
+flutter analyze
+```
+
+**Types Location:** `lib/types.dart` (4,325+ lines, auto-generated)
+
+### 3. Native Code Modifications
+
+#### iOS Native Code
+
+**Location:** `ios/Classes/`
+
+Key files to update:
+
+- `FlutterInappPurchasePlugin.swift` - Main Flutter plugin implementation
+- Method channel handlers for iOS-specific APIs
+
+**When to modify:**
+
+- New iOS-specific API methods added to OpenIAP
+- StoreKit 2 API changes
+- Type conversion between Swift and Dart
+- New method channels needed
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# 1. Update apple version in openiap-versions.json
+# 2. Review openiap/packages/apple/Sources/ for changes
+# 3. Update ios/Classes/FlutterInappPurchasePlugin.swift
+# 4. Update lib/helpers.dart for new type conversions
+```
+
+#### Android Native Code
+
+**Location:** `android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/`
+
+Key files to update:
+
+- `FlutterInappPurchasePlugin.kt` - Main Flutter plugin implementation
+- Method channel handlers for Android-specific APIs
+
+**When to modify:**
+
+- New Android-specific API methods added to OpenIAP
+- Play Billing API changes
+- Type conversion between Kotlin and Dart
+- New method channels needed
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# 1. Update google version in openiap-versions.json
+# 2. Review openiap/packages/google/openiap/src/main/ for changes
+# 3. Update android/.../FlutterInappPurchasePlugin.kt
+# 4. Update lib/helpers.dart for new type conversions
+```
+
+#### macOS Native Code
+
+**Location:** `macos/Classes/`
+
+- Shares implementation pattern with iOS
+- Update alongside iOS changes
+
+### 4. Build & Test Native Code
+
+#### iOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Build iOS (no code sign for testing)
+flutter build ios --no-codesign
+
+# Run on simulator
+cd example
+flutter run -d "iPhone 15 Pro"
+
+# Or build via Xcode
+open example/ios/Runner.xcworkspace
+# Build: Cmd+B, Run: Cmd+R
+```
+
+#### Android Build Test
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Build APK
+flutter build apk --debug
+
+# Run on emulator
+cd example
+flutter run -d emulator-5554
+
+# Or build via Android Studio
+# Open example/android/ in Android Studio
+# Build > Make Project
+```
+
+#### macOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Build macOS
+flutter build macos
+
+# Run
+cd example
+flutter run -d macos
+```
+
+#### Android Horizon Build (Meta Quest)
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# Build with Horizon flavor
+flutter build apk --flavor horizon
+
+# Run example with Horizon
+cd example
+flutter run --flavor horizon
+```
+
+#### Full Build Matrix
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+
+# All platforms
+flutter build ios --no-codesign
+flutter build apk # Play Store
+flutter build apk --flavor horizon # Horizon Store
+flutter build macos
+
+# All tests
+flutter test
+
+# Example app tests
+cd example && flutter test
+```
+
+### 5. Update Helper Functions
+
+If types change, update `lib/helpers.dart`:
+- JSON to object conversions
+- Platform-specific logic
+- Type transformations
+
+### 6. Update Error Handling
+
+If error codes change, update `lib/errors.dart`:
+- Platform error code mappings
+- Exception classes
+
+### 7. Update Example Code
+
+**Location:** `example/lib/src/screens/`
+
+Key screens:
+- `purchase_flow_screen.dart` - Purchase flow demo
+- `subscription_flow_screen.dart` - Subscription demo
+- `alternative_billing_screen.dart` - Android alt billing
+- `offer_code_screen.dart` - Code redemption
+- `builder_demo_screen.dart` - DSL demonstration
+
+### 8. Update Tests
+
+**Unit Tests:** `test/`
+**Example Tests:** `example/test/`
+
+```bash
+# Run all tests
+flutter test
+
+# With coverage (excludes types.dart)
+flutter test --coverage
+```
+
+### 9. Update Documentation
+
+**Location:** `docs/`
+- Docusaurus site
+- `docs/docs/api/` - API reference
+- `docs/docs/guides/` - Usage guides
+- `docs/docs/examples/` - Code examples
+
+### 10. Update llms.txt Files
+
+**Location:** `docs/static/`
+
+Update AI-friendly documentation files when APIs or types change:
+
+- `docs/static/llms.txt` - Quick reference for AI assistants
+- `docs/static/llms-full.txt` - Detailed AI reference
+
+**When to update:**
+
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- Usage patterns updated
+- Error codes changed
+
+**Content to sync:**
+
+1. Installation (pubspec.yaml)
+2. Core API reference (FlutterInappPurchase class)
+3. Key types (Product, Purchase, ErrorCode)
+4. Common usage patterns (fetch, purchase, finish)
+5. Platform-specific APIs (iOS/Android suffixes)
+6. Error handling examples
+
+### 11. Pre-commit Checklist
+
+```bash
+# Format (excludes types.dart)
+git ls-files '*.dart' | grep -v '^lib/types.dart$' | xargs dart format --page-width 80 --output=none --set-exit-if-changed
+
+# Lint
+flutter analyze
+
+# Test
+flutter test
+
+# Final format check
+dart format --set-exit-if-changed .
+
+# Or run all checks
+./scripts/pre-commit-checks.sh
+```
+
+## API Patterns
+
+### Generic Fetch
+```dart
+final products = await FlutterInappPurchase.instance.fetchProducts(
+ productIds: ['product_id'],
+);
+```
+
+### Request Purchase
+```dart
+final result = await FlutterInappPurchase.instance.requestPurchase(
+ RequestPurchaseParams(
+ sku: 'product_id',
+ // platform-specific options
+ ),
+);
+```
+
+### Handler Typedefs
+```dart
+// Use QueryHandlers, MutationHandlers with typed callbacks
+QueryHandlers handlers = QueryHandlers(
+ onProducts: (List products) { ... },
+);
+```
+
+## Naming Conventions
+
+- **iOS types:** `IOS` suffix (e.g., `PurchaseIOS`, `SubscriptionOfferIOS`)
+- **Android types:** `Android` suffix (e.g., `PurchaseAndroid`)
+- **IAP codes:** `Iap` when not final suffix (e.g., `IapPurchase`)
+- **ID fields:** Always `Id` (e.g., `productId`, `subscriptionGroupIdIOS`)
+
+## Deprecation Check
+
+```bash
+cd $IAP_REPOS_HOME/flutter_inapp_purchase
+grep -r "@deprecated" lib/
+grep -r "@Deprecated" lib/
+grep -r "DEPRECATED" lib/
+```
+
+Known deprecations:
+- `getPurchaseHistories()` -> Use `getAvailablePurchases()`
+
+## Commit Message Format
+
+```
+feat: add discount offer support
+fix: resolve iOS purchase verification
+docs: update subscription flow guide
+```
+
+## References
+
+- **CLAUDE.md:** `$IAP_REPOS_HOME/flutter_inapp_purchase/CLAUDE.md`
+- **CONVENTION.md:** `$IAP_REPOS_HOME/flutter_inapp_purchase/CONVENTION.md`
+- **OpenIAP Docs:** https://openiap.dev/docs
+- **flutter_inapp_purchase Docs:** https://hyochan.github.io/flutter_inapp_purchase
diff --git a/.claude/commands/sync-godot-iap.md b/.claude/commands/sync-godot-iap.md
new file mode 100644
index 00000000..e7a3d5e3
--- /dev/null
+++ b/.claude/commands/sync-godot-iap.md
@@ -0,0 +1,292 @@
+# Sync Changes to godot-iap
+
+Synchronize OpenIAP changes to the [godot-iap](https://github.com/hyochan/godot-iap) repository.
+
+**Target Repository:** `$IAP_REPOS_HOME/godot-iap`
+
+> **Note:** Set `IAP_REPOS_HOME` environment variable (see [sync-all-platforms.md](./sync-all-platforms.md#environment-setup))
+
+## Project Overview
+
+- **Engine:** Godot 4.x
+- **Language:** GDScript
+- **Framework:** Godot Plugin/Addon
+- **OpenIAP Version Tracking:** `openiap-versions.json`
+
+## Key Files
+
+| File | Purpose | Auto-Generated |
+|------|---------|----------------|
+| `addons/openiap/types.gd` | GDScript types from OpenIAP | YES |
+| `addons/openiap/iap.gd` | Core IAP module | NO |
+| `addons/openiap/store.gd` | SwiftUI-like store | NO |
+| `plugin.cfg` | Plugin configuration | NO |
+| `openiap-versions.json` | Version tracking | NO |
+
+## Type Generation Source
+
+**OpenIAP has a built-in GDScript type generator:**
+
+- **Generator:** `$OPENIAP_HOME/openiap/packages/gql/scripts/generate-gdscript-types.mjs`
+- **Output:** `$OPENIAP_HOME/openiap/packages/gql/src/generated/types.gd`
+
+## Sync Steps
+
+### 0. Pull Latest (REQUIRED)
+
+**Always pull the latest code before starting any sync work:**
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+git pull
+```
+
+### 1. Sync openiap-versions.json (REQUIRED)
+
+**IMPORTANT:** Before generating types, sync version numbers from openiap monorepo.
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+
+# Check current versions in openiap monorepo
+cat $OPENIAP_HOME/openiap/openiap-versions.json
+
+# Update godot-iap's openiap-versions.json to match:
+# - "gql": should match openiap's "gql" version
+# - "apple": should match openiap's "apple" version
+# - "google": should match openiap's "google" version
+```
+
+**Version fields to sync:**
+| Field | Source | Purpose |
+|-------|--------|---------|
+| `gql` | `$OPENIAP_HOME/openiap/openiap-versions.json` | GDScript types version |
+| `apple` | `$OPENIAP_HOME/openiap/openiap-versions.json` | iOS native SDK version |
+| `google` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Android native SDK version |
+
+### 2. Generate Types in OpenIAP
+
+```bash
+cd $OPENIAP_HOME/openiap/packages/gql
+
+# Run GDScript type generation
+npm run generate:gdscript
+
+# Or run all generators
+npm run generate
+```
+
+### 3. Copy Types to godot-iap
+
+```bash
+# Copy generated types
+cp $OPENIAP_HOME/openiap/packages/gql/src/generated/types.gd \
+ $IAP_REPOS_HOME/godot-iap/addons/openiap/types.gd
+```
+
+### 4. Verify Version Tracking
+
+Confirm `openiap-versions.json` in godot-iap matches openiap:
+
+```json
+{
+ "gql": "1.3.11",
+ "apple": "1.3.9",
+ "google": "1.3.21"
+}
+```
+
+### 5. Native Code Modifications
+
+#### iOS Native Code (GDExtension)
+
+**Location:** `ios/` or `gdextension/ios/`
+
+Key files to update:
+
+- Swift/Objective-C bridge to StoreKit 2
+- GDExtension bindings for iOS
+
+**When to modify:**
+
+- New iOS-specific API methods added to OpenIAP
+- StoreKit 2 API changes
+- Type conversion changes
+
+#### Android Native Code (GDExtension)
+
+**Location:** `android/` or `gdextension/android/`
+
+Key files to update:
+
+- Kotlin/Java bridge to Play Billing
+- GDExtension bindings for Android
+
+**When to modify:**
+
+- New Android-specific API methods added to OpenIAP
+- Play Billing API changes
+- Type conversion changes
+
+### 6. Update GDScript Implementation
+
+If API changes, update:
+
+- `addons/openiap/iap.gd` - Core module
+- `addons/openiap/store.gd` - Store abstraction
+
+### 7. Build & Test
+
+#### Editor Test
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+
+# Open project in Godot 4.x
+godot --editor .
+
+# Check for GDScript errors in Output panel
+# Run test scenes from editor
+```
+
+#### iOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+
+# Export iOS build from Godot Editor
+# Project > Export > iOS
+# Or use CLI:
+godot --headless --export-release "iOS" build/ios/godot-iap.ipa
+```
+
+#### Android Build Test
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+
+# Export Android build from Godot Editor
+# Project > Export > Android
+# Or use CLI:
+godot --headless --export-release "Android" build/android/godot-iap.apk
+```
+
+#### Unit Tests (GDUnit4)
+
+```bash
+# Run GDUnit4 tests
+godot --headless -s addons/gdunit4/test_runner.gd
+
+# Or run from editor: GDUnit4 panel > Run Tests
+```
+
+### 8. Update Example Code
+
+**Location:** `examples/`
+
+- Example Godot scenes demonstrating purchase flows
+- Sample GDScript code
+
+### 9. Update Documentation
+
+**Location:** `docs/` or `README.md`
+
+### 10. Update llms.txt Files
+
+**Location:** `docs/static/`
+
+Update AI-friendly documentation files when APIs or types change:
+
+- `docs/static/llms.txt` - Quick reference for AI assistants
+- `docs/static/llms-full.txt` - Detailed AI reference
+
+**When to update:**
+
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- GDScript patterns updated
+- Error codes changed
+
+**Content to sync:**
+
+1. Installation (Godot Asset Library)
+2. Core API reference (IAP module, Store class)
+3. Key types (ProductIOS, PurchaseAndroid, etc.)
+4. Signal patterns for purchase events
+5. Platform-specific notes (StoreKit 2, Play Billing)
+6. Error handling examples
+
+## Generated Type Structure
+
+```gdscript
+# Enums
+enum ErrorCode {
+ UNKNOWN,
+ USER_CANCELLED,
+ # ... more codes
+}
+
+# Classes
+class ProductIOS:
+ var product_id: String
+ var display_name: String
+ # ... more fields
+
+ static func from_dict(data: Dictionary) -> ProductIOS:
+ # JSON deserialization
+ pass
+
+ func to_dict() -> Dictionary:
+ # JSON serialization
+ pass
+```
+
+## Naming Conventions (GDScript)
+
+- **Classes:** PascalCase (e.g., `ProductIOS`, `RequestPurchaseResult`)
+- **Methods:** snake_case (e.g., `from_dict()`, `to_dict()`)
+- **Fields:** snake_case (e.g., `product_id`, `display_name`)
+- **Enums:** UPPER_CASE (e.g., `ErrorCode.USER_CANCELLED`)
+- **iOS types:** `IOS` suffix
+- **Android types:** `Android` suffix
+
+## Deprecation Check
+
+```bash
+cd $IAP_REPOS_HOME/godot-iap
+grep -r "deprecated" addons/
+grep -r "DEPRECATED" addons/
+```
+
+## Platform-Specific Notes
+
+**iOS:**
+- Uses StoreKit 2 via Swift bridge
+- Check Apple-specific types in generated code
+
+**Android:**
+- Uses Google Play Billing via Java/Kotlin bridge
+- Check Android-specific types in generated code
+
+## Pre-commit Checklist
+
+1. Types generated and copied
+2. Implementation updated for new types
+3. Examples updated
+4. Tests passing
+5. Documentation updated
+
+## Commit Message Format
+
+```
+feat: add discount offer support
+fix: resolve iOS purchase verification
+docs: update subscription flow guide
+```
+
+## References
+
+- **OpenIAP GDScript Generator:** `$OPENIAP_HOME/openiap/packages/gql/scripts/generate-gdscript-types.mjs`
+- **OpenIAP Docs:** https://openiap.dev/docs
+- **Godot IAP Docs:** Check README.md
diff --git a/.claude/commands/sync-kmp-iap.md b/.claude/commands/sync-kmp-iap.md
new file mode 100644
index 00000000..9575b7ed
--- /dev/null
+++ b/.claude/commands/sync-kmp-iap.md
@@ -0,0 +1,348 @@
+# Sync Changes to kmp-iap
+
+Synchronize OpenIAP changes to the [kmp-iap](https://github.com/hyochan/kmp-iap) repository.
+
+**Target Repository:** `$IAP_REPOS_HOME/kmp-iap`
+
+> **Note:** Set `IAP_REPOS_HOME` environment variable (see [sync-all-platforms.md](./sync-all-platforms.md#environment-setup))
+
+## Project Overview
+
+- **Build Tool:** Gradle (Kotlin Multiplatform)
+- **Framework:** Kotlin Multiplatform (KMP)
+- **Targets:** Android, iOS (arm64, x64, simulator-arm64)
+- **OpenIAP Version Tracking:** `openiap-versions.json`
+
+## Key Files
+
+| File | Purpose | Auto-Generated |
+|------|---------|----------------|
+| `library/src/commonMain/.../openiap/Types.kt` | Kotlin types from OpenIAP | YES |
+| `library/src/commonMain/.../KmpIap.kt` | Main interface & type aliases | NO |
+| `library/src/androidMain/.../InAppPurchaseAndroid.kt` | Android implementation | NO |
+| `library/src/iosMain/.../InAppPurchaseIOS.kt` | iOS implementation | NO |
+| `library/src/commonMain/.../dsl/PurchaseDsl.kt` | DSL builders | NO |
+| `library/src/commonMain/.../utils/ErrorMapping.kt` | Error code mapping | NO |
+| `openiap-versions.json` | Version tracking | NO |
+
+## Version File Structure
+
+```json
+{
+ "gql": "1.2.5", // GraphQL schema version (for Types.kt)
+ "google": "1.3.7", // Android openiap-google version
+ "apple": "1.2.39", // iOS openiap pod version
+ "kmp-iap": "1.0.0-rc.6" // This library version
+}
+```
+
+## Sync Steps
+
+### 0. Pull Latest (REQUIRED)
+
+**Always pull the latest code before starting any sync work:**
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+git pull
+```
+
+### 1. Sync openiap-versions.json (REQUIRED)
+
+**IMPORTANT:** Before generating types, sync version numbers from openiap monorepo.
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# Check current versions in openiap monorepo
+cat $OPENIAP_HOME/openiap/openiap-versions.json
+
+# Update kmp-iap's openiap-versions.json to match:
+# - "gql": should match openiap's "gql" version
+# - "apple": should match openiap's "apple" version
+# - "google": should match openiap's "google" version
+```
+
+**Version fields to sync:**
+| Field | Source | Purpose |
+|-------|--------|---------|
+| `gql` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Kotlin types version |
+| `apple` | `$OPENIAP_HOME/openiap/openiap-versions.json` | iOS native SDK version |
+| `google` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Android native SDK version |
+
+### 2. Type Synchronization
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# Download and regenerate types (uses versions from openiap-versions.json)
+./scripts/generate-types.sh
+
+# Verify build
+./gradlew :library:compileDebugKotlin
+```
+
+**Types Location:** `library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt`
+
+### 3. Native Code Modifications
+
+#### Android Implementation
+
+**Location:** `library/src/androidMain/kotlin/io/github/hyochan/kmpiap/`
+
+Key files to update:
+
+- `InAppPurchaseAndroid.kt` (879 lines) - Main Android implementation
+- `Helper.kt` (312 lines) - Android utility functions
+- `Platform.kt` - Platform detection
+- `KmpIAP.android.kt` - Android entry point
+
+**When to modify:**
+
+- New Android-specific API methods added to OpenIAP
+- Play Billing API changes
+- Type mapping changes
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# 1. Update google version in openiap-versions.json
+# 2. Review openiap/packages/google/openiap/src/main/ for changes
+# 3. Update library/src/androidMain/.../InAppPurchaseAndroid.kt
+# 4. Update Helper.kt for new type conversions
+```
+
+#### iOS Implementation
+
+**Location:** `library/src/iosMain/kotlin/io/github/hyochan/kmpiap/`
+
+Key files to update:
+
+- `InAppPurchaseIOS.kt` (880 lines) - Main iOS implementation
+- `Platform.kt` - Platform detection
+- `KmpIAP.ios.kt` - iOS entry point
+- `nativeInterop/cinterop/` - C-Interop declarations for Swift
+
+**When to modify:**
+
+- New iOS-specific API methods added to OpenIAP
+- StoreKit 2 API changes
+- Swift interop changes
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# 1. Update apple version in openiap-versions.json
+# 2. Review openiap/packages/apple/Sources/ for changes
+# 3. Update library/src/iosMain/.../InAppPurchaseIOS.kt
+# 4. Update cinterop if Swift API signature changed
+```
+
+### 4. Build & Test Native Code
+
+#### Android Build Test
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# Compile Android library
+./gradlew :library:compileDebugKotlin
+./gradlew :library:compileReleaseKotlin
+
+# Build example app
+./gradlew :example:composeApp:assembleDebug
+
+# Run Android tests
+./gradlew :library:testDebugUnitTest
+
+# Run on emulator (from Android Studio)
+# Open in Android Studio, run example/composeApp
+```
+
+#### iOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# Build iOS framework
+./gradlew :library:linkDebugFrameworkIosSimulatorArm64
+./gradlew :library:linkReleaseFrameworkIosArm64
+
+# Build example iOS app
+cd example/iosApp
+pod install --repo-update
+
+# Build via Xcode
+xcodebuild -workspace iosApp.xcworkspace \
+ -scheme iosApp \
+ -sdk iphonesimulator \
+ -destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
+ build
+
+# Or open in Xcode and build
+open iosApp.xcworkspace
+```
+
+#### Full Build Matrix
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+
+# All tests
+./gradlew :library:test
+
+# Full library build
+./gradlew :library:build
+
+# Code quality
+./gradlew :library:detekt
+
+# Publish locally for testing
+./gradlew publishToMavenLocal
+```
+
+### 5. Update Type Aliases
+
+If new types added, update `KmpIap.kt`:
+```kotlin
+typealias NewType = io.github.hyochan.kmpiap.openiap.NewType
+```
+
+### 6. Update DSL Builders
+
+If new request types added, update `dsl/PurchaseDsl.kt`:
+```kotlin
+class NewRequestBuilder {
+ // ...
+}
+```
+
+### 7. Update Example Code
+
+**Location:** `example/composeApp/`
+- Compose Multiplatform shared UI
+- iOS app: `example/iosApp/`
+
+### 8. Update Tests
+
+**Location:** `library/src/commonTest/`
+
+```bash
+./gradlew :library:test
+./gradlew :library:build
+```
+
+### 9. Update Documentation
+
+**Location:** `docs/`
+- `docs/docs/api/` - API documentation
+- `docs/docs/examples/` - Code examples
+- `docs/docs/guides/` - Usage guides
+
+### 10. Update llms.txt Files
+
+**Location:** `docs/static/`
+
+Update AI-friendly documentation files when APIs or types change:
+
+- `docs/static/llms.txt` - Quick reference for AI assistants
+- `docs/static/llms-full.txt` - Detailed AI reference
+
+**When to update:**
+
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- DSL builders updated
+- Error codes changed
+
+**Content to sync:**
+
+1. Installation (Gradle dependencies)
+2. Core API reference (KmpIAP interface, DSL patterns)
+3. Key types (Product, Purchase, ErrorCode)
+4. Platform-specific patterns (ios/android DSL blocks)
+5. Error handling examples
+
+### 11. Pre-commit Checklist
+
+```bash
+./gradlew :library:test
+./gradlew :library:build
+./gradlew :library:detekt
+```
+
+## API Patterns
+
+### Global Instance Pattern
+```kotlin
+val products = kmpIapInstance.fetchProducts {
+ skus = listOf("product_id")
+ type = ProductQueryType.InApp
+}
+```
+
+### Constructor Pattern (for testing)
+```kotlin
+val kmpIAP = KmpIAP()
+kmpIAP.initConnection()
+```
+
+### Platform-Specific DSL
+```kotlin
+val purchase = kmpIapInstance.requestPurchase {
+ ios { sku = "product_id"; quantity = 1 }
+ android { skus = listOf("product_id") }
+}
+```
+
+## Naming Conventions
+
+- **IAP suffix:** For types contrasting with iOS (e.g., `IapPurchase`)
+- **ID fields:** Always `Id` (e.g., `productId`, `transactionId`)
+- **iOS types:** `IOS` suffix when iOS-specific
+- **Android types:** `Android` suffix when Android-specific
+
+## Deprecation Check
+
+```bash
+cd $IAP_REPOS_HOME/kmp-iap
+grep -r "@Deprecated" library/src/
+grep -r "DEPRECATED" library/src/
+```
+
+## Build Commands
+
+```bash
+# Type generation
+./scripts/generate-types.sh
+
+# Building
+./gradlew :library:build
+./gradlew :library:compileDebugKotlin
+./gradlew :example:composeApp:assembleDebug
+
+# Testing
+./gradlew :library:test
+
+# Publishing
+./gradlew publishAllPublicationsToMavenCentral
+```
+
+## Commit Message Format
+
+```
+feat: add discount offer support
+fix: resolve iOS purchase verification
+docs: update subscription flow guide
+```
+
+## References
+
+- **CLAUDE.md:** `$IAP_REPOS_HOME/kmp-iap/CLAUDE.md`
+- **CONVENTION.md:** `$IAP_REPOS_HOME/kmp-iap/CONVENTION.md`
+- **OpenIAP Docs:** https://openiap.dev/docs
diff --git a/.claude/commands/sync-react-native-iap.md b/.claude/commands/sync-react-native-iap.md
new file mode 100644
index 00000000..38c9ded1
--- /dev/null
+++ b/.claude/commands/sync-react-native-iap.md
@@ -0,0 +1,321 @@
+# Sync Changes to react-native-iap
+
+Synchronize OpenIAP changes to the [react-native-iap](https://github.com/hyochan/react-native-iap) repository.
+
+**Target Repository:** `$IAP_REPOS_HOME/react-native-iap`
+
+> **Note:** Set `IAP_REPOS_HOME` environment variable (see [sync-all-platforms.md](./sync-all-platforms.md#environment-setup))
+
+## Project Overview
+
+- **Package Manager:** Yarn 3 (workspace)
+- **Framework:** React Native with Nitro Modules
+- **Current Version:** Check `package.json`
+- **OpenIAP Version Tracking:** `openiap-versions.json`
+
+## Key Files
+
+| File | Purpose | Auto-Generated |
+|------|---------|----------------|
+| `src/types.ts` | TypeScript types from OpenIAP | YES |
+| `src/specs/RnIap.nitro.ts` | Nitro bridge spec | NO |
+| `src/utils/type-bridge.ts` | Type converters | NO |
+| `src/index.ts` | Public API | NO |
+| `src/hooks/useIAP.ts` | React hook | NO |
+| `ios/HybridRnIap.swift` | iOS implementation | NO |
+| `android/.../HybridRnIap.kt` | Android implementation | NO |
+| `nitrogen/generated/` | Nitro bridge code | YES |
+| `openiap-versions.json` | Version tracking | NO |
+
+## Sync Steps
+
+### 0. Pull Latest (REQUIRED)
+
+**Always pull the latest code before starting any sync work:**
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+git pull
+```
+
+### 1. Sync openiap-versions.json (REQUIRED)
+
+**IMPORTANT:** Before generating types, sync version numbers from openiap monorepo.
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# Check current versions in openiap monorepo
+cat $OPENIAP_HOME/openiap/openiap-versions.json
+
+# Update react-native-iap's openiap-versions.json to match:
+# - "gql": should match openiap's "gql" version
+# - "apple": should match openiap's "apple" version
+# - "google": should match openiap's "google" version
+```
+
+**Version fields to sync:**
+| Field | Source | Purpose |
+|-------|--------|---------|
+| `gql` | `$OPENIAP_HOME/openiap/openiap-versions.json` | TypeScript types version |
+| `apple` | `$OPENIAP_HOME/openiap/openiap-versions.json` | iOS native SDK version |
+| `google` | `$OPENIAP_HOME/openiap/openiap-versions.json` | Android native SDK version |
+
+### 2. Type Synchronization
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# Download and regenerate types (uses versions from openiap-versions.json)
+yarn generate:types
+
+# Verify types
+yarn typecheck
+```
+
+### 3. Native Code Modifications
+
+#### iOS Native Code
+
+**Location:** `ios/`
+
+Key files to update:
+
+- `ios/HybridRnIap.swift` - Main iOS implementation (wraps OpenIAP Swift)
+- `ios/RnIapHelper.swift` - Helper utilities
+- `ios/RnIapLog.swift` - Logging utilities
+- `NitroIap.podspec` - CocoaPods spec
+
+**When to modify:**
+
+- New iOS-specific API methods added to OpenIAP
+- StoreKit 2 API signature changes
+- Type conversion between Swift and Nitro
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# 1. Update apple version in openiap-versions.json
+# 2. Review openiap/packages/apple/Sources/ for changes
+# 3. Update ios/HybridRnIap.swift accordingly
+# 4. Update type-bridge.ts if new types need conversion
+
+# Install updated pod
+cd example/ios && bundle exec pod install --repo-update
+```
+
+#### Android Native Code
+
+**Location:** `android/src/main/java/com/margelo/nitro/iap/`
+
+Key files to update:
+
+- `HybridRnIap.kt` - Main Android implementation (wraps OpenIAP Kotlin)
+- `RnIapLog.kt` - Logging utilities
+
+**When to modify:**
+
+- New Android-specific API methods added to OpenIAP
+- Play Billing API changes
+- Type conversion between Kotlin and Nitro
+
+**Update workflow:**
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# 1. Update google version in openiap-versions.json
+# 2. Review openiap/packages/google/openiap/src/main/ for changes
+# 3. Update android/.../HybridRnIap.kt accordingly
+# 4. Update type-bridge.ts if new types need conversion
+```
+
+### 4. Nitro Bridge Updates
+
+If types changed that affect the bridge:
+```bash
+# Regenerate Nitro bridge files
+yarn specs
+
+# Verify bridge code
+yarn prepare
+```
+
+### 5. Build & Test Native Code
+
+#### iOS Build Test
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# Install dependencies
+yarn install
+
+# Install pods for example
+cd example/ios && bundle exec pod install --repo-update && cd ../..
+
+# Build and run on simulator
+yarn workspace rn-iap-example ios
+
+# Or build via Xcode
+open example/ios/RnIapExample.xcworkspace
+# Build: Cmd+B, Run: Cmd+R
+```
+
+#### Android Build Test
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+
+# Build and run on emulator
+yarn workspace rn-iap-example android
+
+# Or build via Android Studio
+# Open example/android/ in Android Studio
+# Build > Make Project
+# Run > Run 'app'
+```
+
+#### Expo Example Test
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap/example-expo
+
+bun setup
+bun ios # iOS simulator
+bun android # Android emulator
+```
+
+#### Android Horizon Build (Meta Quest)
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap/example
+
+# Enable Horizon flavor in gradle.properties
+echo "horizonEnabled=true" >> android/gradle.properties
+
+# Build with Horizon
+yarn android
+
+# Revert for Play Store builds
+sed -i '' '/horizonEnabled=true/d' android/gradle.properties
+```
+
+### 6. Update Example Code
+
+**React Native Example:** `example/`
+
+Key screens to update:
+
+- `example/src/screens/` - Main app screens
+- `example/navigation/` - Navigation setup
+
+**Expo Example:** `example-expo/app/`
+
+### 7. Update Tests
+
+**Location:** `src/__tests__/`
+
+```bash
+yarn test # Unit tests
+yarn test:all # Library + example tests
+yarn test:ci # CI environment
+yarn test:plugin # Expo plugin tests
+```
+
+### 8. Update Documentation
+
+**Location:** `docs/`
+- Docusaurus site
+- Package manager: Bun
+
+### 9. Update llms.txt Files
+
+**Location:** `docs/static/`
+
+Update AI-friendly documentation files when APIs or types change:
+
+- `docs/static/llms.txt` - Quick reference for AI assistants
+- `docs/static/llms-full.txt` - Detailed AI reference
+
+**When to update:**
+
+- New API functions added
+- Function signatures changed
+- New types or enums added
+- Usage patterns updated
+- Error codes changed
+
+**Content to sync:**
+
+1. Installation commands
+2. Core API reference (useIAP hook, direct functions)
+3. Key types (Product, Purchase, ErrorCode)
+4. Common usage patterns
+5. Platform-specific APIs (iOS/Android suffixes)
+6. Error handling examples
+
+### 10. Pre-commit Checklist
+
+```bash
+yarn typecheck # TypeScript
+yarn lint # ESLint
+yarn lint:prettier # Prettier
+yarn test # Tests
+```
+
+## Type Conversion Flow
+
+```
+OpenIAP Types (openiap/packages/gql)
+ ↓ (downloaded via update-types.mjs)
+src/types.ts (auto-generated)
+ ↓ (imported and type-aliased)
+src/specs/RnIap.nitro.ts (Nitro spec)
+ ↓ (generated by nitrogen tool)
+nitrogen/generated/ (C++ bridge code)
+ ↓ (converted by type-bridge utilities)
+src/utils/type-bridge.ts (conversion functions)
+ ↓ (exported as public API)
+src/index.ts (cross-platform API)
+```
+
+## Naming Conventions
+
+- **iOS-only:** `functionNameIOS` (e.g., `clearTransactionIOS`)
+- **Android-only:** `functionNameAndroid` (e.g., `acknowledgePurchaseAndroid`)
+- **Cross-platform:** No suffix (e.g., `fetchProducts`)
+- **iOS types:** `ProductIOS`, `PurchaseIOS`
+- **ID fields:** `Id` not `ID` (e.g., `productId`, `transactionId`)
+
+## Deprecation Check
+
+```bash
+cd $IAP_REPOS_HOME/react-native-iap
+grep -r "@deprecated" src/
+grep -r "DEPRECATED" src/
+```
+
+## Error Handling
+
+**Key files:**
+- `src/utils/error.ts` - Error parsing
+- `src/utils/errorMapping.ts` - Native error code mapping
+
+Update if `ErrorCode` enum changes in OpenIAP.
+
+## Commit Message Format
+
+```
+feat: add discount offer support
+fix: resolve iOS purchase verification
+docs: update subscription flow guide
+```
+
+## References
+
+- **CLAUDE.md:** `$IAP_REPOS_HOME/react-native-iap/CLAUDE.md`
+- **OpenIAP Docs:** https://openiap.dev/docs
+- **react-native-iap Docs:** https://react-native-iap.dooboolab.com
diff --git a/packages/apple/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift b/packages/apple/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift
index e4bd3b5c..4185f1f7 100644
--- a/packages/apple/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift
+++ b/packages/apple/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift
@@ -82,6 +82,21 @@ struct SubscriptionCard: View {
.padding(.vertical, 2)
.background(AppColors.secondary.opacity(0.15))
.cornerRadius(4)
+
+ // Show intro/promotional offer using new standardized SubscriptionOffer type
+ if let offer = product?.subscriptionOffers?.first,
+ offer.type == .introductory || offer.type == .promotional {
+ let offerText = offer.paymentMode == .freeTrial
+ ? "Free Trial"
+ : offer.displayPrice
+ Label(offerText, systemImage: "tag.fill")
+ .font(.caption2)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(AppColors.success.opacity(0.2))
+ .foregroundColor(AppColors.success)
+ .cornerRadius(4)
+ }
}
}
diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
index 728d4916..4cd85257 100644
--- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
+++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift
@@ -69,6 +69,9 @@ enum StoreKitTypesBridge {
return normalized.unit.subscriptionPeriodIOS
}()
+ // Convert to standardized cross-platform SubscriptionOffer type
+ let standardizedOffers = makeStandardizedSubscriptionOffers(from: subscription)
+
return ProductSubscriptionIOS(
currency: product.priceFormatStyle.currencyCode,
debugDescription: product.description,
@@ -88,6 +91,7 @@ enum StoreKitTypesBridge {
platform: .ios,
price: NSDecimalNumber(decimal: product.price).doubleValue,
subscriptionInfoIOS: makeSubscriptionInfo(from: product.subscription),
+ subscriptionOffers: standardizedOffers.isEmpty ? nil : standardizedOffers,
subscriptionPeriodNumberIOS: String(subscription.subscriptionPeriod.value),
subscriptionPeriodUnitIOS: subscription.subscriptionPeriod.unit.subscriptionPeriodIOS,
title: product.displayName,
@@ -431,6 +435,51 @@ private extension StoreKitTypesBridge {
)
}
+ /// Converts StoreKit subscription offers to standardized cross-platform SubscriptionOffer type.
+ /// This is the new format that works across iOS and Android.
+ static func makeStandardizedSubscriptionOffers(from subscription: StoreKit.Product.SubscriptionInfo) -> [SubscriptionOffer] {
+ var offers: [SubscriptionOffer] = []
+
+ // Add introductory offer if available
+ if let intro = subscription.introductoryOffer {
+ offers.append(makeStandardizedSubscriptionOffer(from: intro, type: .introductory))
+ }
+
+ // Add promotional offers
+ for promo in subscription.promotionalOffers {
+ offers.append(makeStandardizedSubscriptionOffer(from: promo, type: .promotional))
+ }
+
+ return offers
+ }
+
+ /// Converts a single StoreKit subscription offer to standardized SubscriptionOffer.
+ static func makeStandardizedSubscriptionOffer(from offer: StoreKit.Product.SubscriptionOffer, type: DiscountOfferType) -> SubscriptionOffer {
+ SubscriptionOffer(
+ basePlanIdAndroid: nil,
+ currency: nil, // iOS doesn't provide currency at offer level
+ displayPrice: offer.displayPrice,
+ id: offer.id ?? "",
+ keyIdentifierIOS: nil, // Not available from product, needs server-side generation
+ localizedPriceIOS: offer.displayPrice,
+ nonceIOS: nil, // Needs server-side generation
+ numberOfPeriodsIOS: offer.periodCount,
+ offerTagsAndroid: nil,
+ offerTokenAndroid: nil,
+ paymentMode: offer.paymentMode.standardizedPaymentMode,
+ period: SubscriptionPeriod(
+ unit: offer.period.unit.standardizedPeriodUnit,
+ value: offer.period.value
+ ),
+ periodCount: offer.periodCount,
+ price: NSDecimalNumber(decimal: offer.price).doubleValue,
+ pricingPhasesAndroid: nil,
+ signatureIOS: nil, // Needs server-side generation
+ timestampIOS: nil,
+ type: type
+ )
+ }
+
static func makeDiscounts(from subscription: StoreKit.Product.SubscriptionInfo, product: StoreKit.Product) -> [DiscountIOS]? {
var discounts: [DiscountIOS] = []
@@ -734,6 +783,16 @@ private extension StoreKit.Product.SubscriptionOffer.PaymentMode {
default: return .empty
}
}
+
+ /// Converts to standardized cross-platform PaymentMode enum.
+ var standardizedPaymentMode: PaymentMode {
+ switch self {
+ case .freeTrial: return .freeTrial
+ case .payAsYouGo: return .payAsYouGo
+ case .payUpFront: return .payUpFront
+ default: return .unknown
+ }
+ }
}
@available(iOS 15.0, macOS 14.0, *)
@@ -747,6 +806,17 @@ private extension StoreKit.Product.SubscriptionPeriod.Unit {
default: return .empty
}
}
+
+ /// Converts to standardized cross-platform SubscriptionPeriodUnit enum.
+ var standardizedPeriodUnit: SubscriptionPeriodUnit {
+ switch self {
+ case .day: return .day
+ case .week: return .week
+ case .month: return .month
+ case .year: return .year
+ default: return .unknown
+ }
+ }
}
@available(iOS 15.0, macOS 14.0, *)
diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift
index 505e76a8..656e0b20 100644
--- a/packages/apple/Sources/Models/Types.swift
+++ b/packages/apple/Sources/Models/Types.swift
@@ -62,6 +62,17 @@ public enum DeveloperBillingLaunchModeAndroid: String, Codable, CaseIterable {
case callerWillLaunchLink = "caller-will-launch-link"
}
+/// Discount offer type enumeration.
+/// Categorizes the type of discount or promotional offer.
+public enum DiscountOfferType: String, Codable, CaseIterable {
+ /// Introductory offer for new subscribers (first-time purchase discount)
+ case introductory = "introductory"
+ /// Promotional offer for existing or returning subscribers
+ case promotional = "promotional"
+ /// One-time product discount (Android only, Google Play Billing 7.0+)
+ case oneTime = "one-time"
+}
+
public enum ErrorCode: String, Codable, CaseIterable {
case unknown = "unknown"
case userCancelled = "user-cancelled"
@@ -262,6 +273,19 @@ public enum IapStore: String, Codable, CaseIterable {
case horizon = "horizon"
}
+/// Payment mode for subscription offers.
+/// Determines how the user pays during the offer period.
+public enum PaymentMode: String, Codable, CaseIterable {
+ /// Free trial period - no charge during offer
+ case freeTrial = "free-trial"
+ /// Pay each period at reduced price
+ case payAsYouGo = "pay-as-you-go"
+ /// Pay full discounted amount upfront
+ case payUpFront = "pay-up-front"
+ /// Unknown or unspecified payment mode
+ case unknown = "unknown"
+}
+
public enum PaymentModeIOS: String, Codable, CaseIterable {
case empty = "empty"
case freeTrial = "free-trial"
@@ -310,6 +334,15 @@ public enum SubscriptionPeriodIOS: String, Codable, CaseIterable {
case empty = "empty"
}
+/// Subscription period unit for cross-platform use.
+public enum SubscriptionPeriodUnit: String, Codable, CaseIterable {
+ case day = "day"
+ case week = "week"
+ case month = "month"
+ case year = "year"
+ case unknown = "unknown"
+}
+
/// Replacement mode for subscription changes (Android)
/// These modes determine how the subscription replacement affects billing.
/// Available in Google Play Billing Library 8.1.0+
@@ -460,6 +493,9 @@ public struct DiscountDisplayInfoAndroid: Codable {
public var percentageDiscount: Int?
}
+/// Discount information returned from the store.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct DiscountIOS: Codable {
public var identifier: String
public var localizedPrice: String?
@@ -471,6 +507,59 @@ public struct DiscountIOS: Codable {
public var type: String
}
+/// Standardized one-time product discount offer.
+/// Provides a unified interface for one-time purchase discounts across platforms.
+///
+/// Currently supported on Android (Google Play Billing 7.0+).
+/// iOS does not support one-time purchase discounts in the same way.
+///
+/// @see https://openiap.dev/docs/features/discount
+public struct DiscountOffer: Codable {
+ /// Currency code (ISO 4217, e.g., "USD")
+ public var currency: String
+ /// [Android] Fixed discount amount in micro-units.
+ /// Only present for fixed amount discounts.
+ public var discountAmountMicrosAndroid: String?
+ /// Formatted display price string (e.g., "$4.99")
+ public var displayPrice: String
+ /// [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ public var formattedDiscountAmountAndroid: String?
+ /// [Android] Original full price in micro-units before discount.
+ /// Divide by 1,000,000 to get the actual price.
+ /// Use for displaying strikethrough original price.
+ public var fullPriceMicrosAndroid: String?
+ /// Unique identifier for the offer.
+ /// - iOS: Not applicable (one-time discounts not supported)
+ /// - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ public var id: String?
+ /// [Android] Limited quantity information.
+ /// Contains maximumQuantity and remainingQuantity.
+ public var limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid?
+ /// [Android] List of tags associated with this offer.
+ public var offerTagsAndroid: [String]?
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ public var offerTokenAndroid: String?
+ /// [Android] Percentage discount (e.g., 33 for 33% off).
+ /// Only present for percentage-based discounts.
+ public var percentageDiscountAndroid: Int?
+ /// [Android] Pre-order details if this is a pre-order offer.
+ /// Available in Google Play Billing Library 8.1.0+
+ public var preorderDetailsAndroid: PreorderDetailsAndroid?
+ /// Numeric price value
+ public var price: Double
+ /// [Android] Rental details if this is a rental offer.
+ public var rentalDetailsAndroid: RentalDetailsAndroid?
+ /// Type of discount offer
+ public var type: DiscountOfferType
+ /// [Android] Valid time window for the offer.
+ /// Contains startTimeMillis and endTimeMillis.
+ public var validTimeWindowAndroid: ValidTimeWindowAndroid?
+}
+
+/// iOS DiscountOffer (output type).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct DiscountOfferIOS: Codable {
/// Discount identifier
public var identifier: String
@@ -565,22 +654,34 @@ public struct ProductAndroid: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ public var discountOffers: [DiscountOffer]?
public var displayName: String?
public var displayPrice: String
public var id: String
public var nameAndroid: String
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]?
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var title: String
public var type: ProductType = .inApp
}
-/// One-time purchase offer details (Android)
+/// One-time purchase offer details (Android).
/// Available in Google Play Billing Library 7.0+
+/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#discount-offer
public struct ProductAndroidOneTimePurchaseOfferDetail: Codable {
/// Discount display information
/// Only available for discounted offers
@@ -620,7 +721,13 @@ public struct ProductIOS: Codable, ProductCommon {
public var jsonRepresentationIOS: String
public var platform: IapPlatform = .ios
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionInfoIOS: SubscriptionInfoIOS?
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// Note: iOS does not support one-time product discounts.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var title: String
public var type: ProductType = .inApp
public var typeIOS: ProductTypeIOS
@@ -630,20 +737,33 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ public var discountOffers: [DiscountOffer]?
public var displayName: String?
public var displayPrice: String
public var id: String
public var nameAndroid: String
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]
public var title: String
public var type: ProductType = .subs
}
+/// Subscription offer details (Android).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct ProductSubscriptionAndroidOfferDetails: Codable {
public var basePlanId: String
public var offerId: String?
@@ -656,6 +776,7 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var discountsIOS: [DiscountIOS]?
public var displayName: String?
public var displayNameIOS: String
@@ -670,7 +791,12 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon {
public var jsonRepresentationIOS: String
public var platform: IapPlatform = .ios
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionInfoIOS: SubscriptionInfoIOS?
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var subscriptionPeriodNumberIOS: String?
public var subscriptionPeriodUnitIOS: SubscriptionPeriodIOS?
public var title: String
@@ -824,6 +950,66 @@ public struct SubscriptionInfoIOS: Codable {
public var subscriptionPeriod: SubscriptionPeriodValueIOS
}
+/// Standardized subscription discount/promotional offer.
+/// Provides a unified interface for subscription offers across iOS and Android.
+///
+/// Both platforms support subscription offers with different implementations:
+/// - iOS: Introductory offers, promotional offers with server-side signatures
+/// - Android: Offer tokens with pricing phases
+///
+/// @see https://openiap.dev/docs/types/ios#discount-offer
+/// @see https://openiap.dev/docs/types/android#subscription-offer
+public struct SubscriptionOffer: Codable {
+ /// [Android] Base plan identifier.
+ /// Identifies which base plan this offer belongs to.
+ public var basePlanIdAndroid: String?
+ /// Currency code (ISO 4217, e.g., "USD")
+ public var currency: String?
+ /// Formatted display price string (e.g., "$9.99/month")
+ public var displayPrice: String
+ /// Unique identifier for the offer.
+ /// - iOS: Discount identifier from App Store Connect
+ /// - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ public var id: String
+ /// [iOS] Key identifier for signature validation.
+ /// Used with server-side signature generation for promotional offers.
+ public var keyIdentifierIOS: String?
+ /// [iOS] Localized price string.
+ public var localizedPriceIOS: String?
+ /// [iOS] Cryptographic nonce (UUID) for signature validation.
+ /// Must be generated server-side for each purchase attempt.
+ public var nonceIOS: String?
+ /// [iOS] Number of billing periods for this discount.
+ public var numberOfPeriodsIOS: Int?
+ /// [Android] List of tags associated with this offer.
+ public var offerTagsAndroid: [String]?
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ public var offerTokenAndroid: String?
+ /// Payment mode during the offer period
+ public var paymentMode: PaymentMode?
+ /// Subscription period for this offer
+ public var period: SubscriptionPeriod?
+ /// Number of periods the offer applies
+ public var periodCount: Int?
+ /// Numeric price value
+ public var price: Double
+ /// [Android] Pricing phases for this subscription offer.
+ /// Contains detailed pricing information for each phase (trial, intro, regular).
+ public var pricingPhasesAndroid: PricingPhasesAndroid?
+ /// [iOS] Server-generated signature for promotional offer validation.
+ /// Required when applying promotional offers on iOS.
+ public var signatureIOS: String?
+ /// [iOS] Timestamp when the signature was generated.
+ /// Used for signature validation.
+ public var timestampIOS: Double?
+ /// Type of subscription offer (Introductory or Promotional)
+ public var type: DiscountOfferType
+}
+
+/// iOS subscription offer details.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct SubscriptionOfferIOS: Codable {
public var displayPrice: String
public var id: String
@@ -834,6 +1020,14 @@ public struct SubscriptionOfferIOS: Codable {
public var type: SubscriptionOfferTypeIOS
}
+/// Subscription period value combining unit and count.
+public struct SubscriptionPeriod: Codable {
+ /// The period unit (day, week, month, year)
+ public var unit: SubscriptionPeriodUnit
+ /// The number of units (e.g., 1 for monthly, 3 for quarterly)
+ public var value: Int
+}
+
public struct SubscriptionPeriodValueIOS: Codable {
public var unit: SubscriptionPeriodIOS
public var value: Int
diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift
index c1d085c7..8a3af1c9 100644
--- a/packages/apple/Tests/OpenIapTests.swift
+++ b/packages/apple/Tests/OpenIapTests.swift
@@ -399,6 +399,180 @@ final class OpenIapTests: XCTestCase {
XCTAssertEqual(errorKebab.message, "Item is already owned")
}
+ // MARK: - Standardized Offer Types Tests
+
+ func testSubscriptionOfferCreation() throws {
+ let offer = SubscriptionOffer(
+ basePlanIdAndroid: nil,
+ currency: "USD",
+ displayPrice: "$0.00",
+ id: "intro_offer_001",
+ keyIdentifierIOS: "key123",
+ localizedPriceIOS: "$0.00",
+ nonceIOS: nil,
+ numberOfPeriodsIOS: 1,
+ offerTagsAndroid: nil,
+ offerTokenAndroid: nil,
+ paymentMode: .freeTrial,
+ period: SubscriptionPeriod(unit: .week, value: 1),
+ periodCount: 1,
+ price: 0.0,
+ pricingPhasesAndroid: nil,
+ signatureIOS: nil,
+ timestampIOS: nil,
+ type: .introductory
+ )
+
+ XCTAssertEqual(offer.id, "intro_offer_001")
+ XCTAssertEqual(offer.displayPrice, "$0.00")
+ XCTAssertEqual(offer.price, 0.0)
+ XCTAssertEqual(offer.type, .introductory)
+ XCTAssertEqual(offer.paymentMode, .freeTrial)
+ XCTAssertEqual(offer.period?.unit, .week)
+ XCTAssertEqual(offer.period?.value, 1)
+ XCTAssertEqual(offer.periodCount, 1)
+ XCTAssertEqual(offer.keyIdentifierIOS, "key123")
+ XCTAssertNil(offer.offerTokenAndroid)
+ }
+
+ func testSubscriptionOfferSerialization() throws {
+ let offer = SubscriptionOffer(
+ basePlanIdAndroid: "monthly_base",
+ currency: "USD",
+ displayPrice: "$4.99",
+ id: "promo_summer",
+ keyIdentifierIOS: nil,
+ localizedPriceIOS: "$4.99",
+ nonceIOS: nil,
+ numberOfPeriodsIOS: 3,
+ offerTagsAndroid: ["summer", "promo"],
+ offerTokenAndroid: "token_abc123",
+ paymentMode: .payAsYouGo,
+ period: SubscriptionPeriod(unit: .month, value: 1),
+ periodCount: 3,
+ price: 4.99,
+ pricingPhasesAndroid: nil,
+ signatureIOS: nil,
+ timestampIOS: nil,
+ type: .promotional
+ )
+
+ // Test encoding/decoding
+ let data = try JSONEncoder().encode(offer)
+ let decoded = try JSONDecoder().decode(SubscriptionOffer.self, from: data)
+
+ XCTAssertEqual(decoded.id, "promo_summer")
+ XCTAssertEqual(decoded.type, .promotional)
+ XCTAssertEqual(decoded.paymentMode, .payAsYouGo)
+ XCTAssertEqual(decoded.basePlanIdAndroid, "monthly_base")
+ XCTAssertEqual(decoded.offerTokenAndroid, "token_abc123")
+ XCTAssertEqual(decoded.offerTagsAndroid, ["summer", "promo"])
+ XCTAssertEqual(decoded.period?.unit, .month)
+ XCTAssertEqual(decoded.periodCount, 3)
+ }
+
+ func testDiscountOfferTypeEnum() {
+ XCTAssertEqual(DiscountOfferType.introductory.rawValue, "introductory")
+ XCTAssertEqual(DiscountOfferType.promotional.rawValue, "promotional")
+ XCTAssertEqual(DiscountOfferType.oneTime.rawValue, "one-time")
+
+ XCTAssertEqual(DiscountOfferType(rawValue: "introductory"), .introductory)
+ XCTAssertEqual(DiscountOfferType(rawValue: "promotional"), .promotional)
+ XCTAssertEqual(DiscountOfferType(rawValue: "one-time"), .oneTime)
+ }
+
+ func testPaymentModeEnum() {
+ XCTAssertEqual(PaymentMode.freeTrial.rawValue, "free-trial")
+ XCTAssertEqual(PaymentMode.payAsYouGo.rawValue, "pay-as-you-go")
+ XCTAssertEqual(PaymentMode.payUpFront.rawValue, "pay-up-front")
+ XCTAssertEqual(PaymentMode.unknown.rawValue, "unknown")
+
+ XCTAssertEqual(PaymentMode(rawValue: "free-trial"), .freeTrial)
+ XCTAssertEqual(PaymentMode(rawValue: "pay-as-you-go"), .payAsYouGo)
+ XCTAssertEqual(PaymentMode(rawValue: "pay-up-front"), .payUpFront)
+ XCTAssertEqual(PaymentMode(rawValue: "unknown"), .unknown)
+ }
+
+ func testSubscriptionPeriodUnitEnum() {
+ XCTAssertEqual(SubscriptionPeriodUnit.day.rawValue, "day")
+ XCTAssertEqual(SubscriptionPeriodUnit.week.rawValue, "week")
+ XCTAssertEqual(SubscriptionPeriodUnit.month.rawValue, "month")
+ XCTAssertEqual(SubscriptionPeriodUnit.year.rawValue, "year")
+ XCTAssertEqual(SubscriptionPeriodUnit.unknown.rawValue, "unknown")
+ }
+
+ func testSubscriptionPeriodSerialization() throws {
+ let period = SubscriptionPeriod(unit: .month, value: 3)
+
+ let data = try JSONEncoder().encode(period)
+ let decoded = try JSONDecoder().decode(SubscriptionPeriod.self, from: data)
+
+ XCTAssertEqual(decoded.unit, .month)
+ XCTAssertEqual(decoded.value, 3)
+ }
+
+ func testProductSubscriptionIOSWithSubscriptionOffers() throws {
+ let standardizedOffer = SubscriptionOffer(
+ basePlanIdAndroid: nil,
+ currency: "USD",
+ displayPrice: "$0.00",
+ id: "intro_weekly",
+ keyIdentifierIOS: nil,
+ localizedPriceIOS: "$0.00",
+ nonceIOS: nil,
+ numberOfPeriodsIOS: 1,
+ offerTagsAndroid: nil,
+ offerTokenAndroid: nil,
+ paymentMode: .freeTrial,
+ period: SubscriptionPeriod(unit: .week, value: 1),
+ periodCount: 1,
+ price: 0.0,
+ pricingPhasesAndroid: nil,
+ signatureIOS: nil,
+ timestampIOS: nil,
+ type: .introductory
+ )
+
+ let product = ProductSubscriptionIOS(
+ currency: "USD",
+ debugDescription: "",
+ description: "Premium subscription with offers",
+ discountsIOS: nil,
+ displayName: "Premium",
+ displayNameIOS: "Premium",
+ displayPrice: "$9.99",
+ id: "dev.hyo.premium",
+ introductoryPriceAsAmountIOS: "0",
+ introductoryPriceIOS: "$0.00",
+ introductoryPriceNumberOfPeriodsIOS: "1",
+ introductoryPricePaymentModeIOS: .freeTrial,
+ introductoryPriceSubscriptionPeriodIOS: .week,
+ isFamilyShareableIOS: true,
+ jsonRepresentationIOS: "{}",
+ platform: .ios,
+ price: 9.99,
+ subscriptionInfoIOS: nil,
+ subscriptionOffers: [standardizedOffer],
+ subscriptionPeriodNumberIOS: "1",
+ subscriptionPeriodUnitIOS: .month,
+ title: "Premium",
+ type: .subs,
+ typeIOS: .autoRenewableSubscription
+ )
+
+ XCTAssertNotNil(product.subscriptionOffers)
+ XCTAssertEqual(product.subscriptionOffers?.count, 1)
+ XCTAssertEqual(product.subscriptionOffers?.first?.id, "intro_weekly")
+ XCTAssertEqual(product.subscriptionOffers?.first?.type, .introductory)
+ XCTAssertEqual(product.subscriptionOffers?.first?.paymentMode, .freeTrial)
+
+ // Test serialization
+ let data = try JSONEncoder().encode(product)
+ let decoded = try JSONDecoder().decode(ProductSubscriptionIOS.self, from: data)
+ XCTAssertEqual(decoded.subscriptionOffers?.count, 1)
+ XCTAssertEqual(decoded.subscriptionOffers?.first?.id, "intro_weekly")
+ }
+
// MARK: - Helpers
private func makeSampleProduct() -> ProductIOS {
diff --git a/packages/docs/src/pages/docs/features/discount.tsx b/packages/docs/src/pages/docs/features/discount.tsx
index a4efac03..a428fc9a 100644
--- a/packages/docs/src/pages/docs/features/discount.tsx
+++ b/packages/docs/src/pages/docs/features/discount.tsx
@@ -30,6 +30,16 @@ function Discount() {
+
+
+ Standardized Types: For cross-platform development,
+ use the new{' '}
+ DiscountOffer type
+ which provides a unified interface with platform-specific fields via
+ suffixes (e.g., offerTokenAndroid).
+
+
+
Overview
diff --git a/packages/docs/src/pages/docs/types/android.tsx b/packages/docs/src/pages/docs/types/android.tsx
index ae931137..7160a1cc 100644
--- a/packages/docs/src/pages/docs/types/android.tsx
+++ b/packages/docs/src/pages/docs/types/android.tsx
@@ -31,12 +31,6 @@ function TypesAndroid() {
{' '}
- One-time purchase discount offers (Billing Library 7.0+)
-
-
- SubscriptionOffer
- {' '}
- - Offer details with offerToken for purchases
-
PricingPhase
@@ -49,9 +43,24 @@ function TypesAndroid() {
{' '}
- Container for pricing phase list
+
+ Deprecated: SubscriptionOffer → Use{' '}
+ standardized Offer Types
+
+
+
+ Deprecation Notice: The Android-specific offer types
+ (ProductAndroidOneTimePurchaseOfferDetail,{' '}
+ ProductSubscriptionAndroidOfferDetails) are deprecated.
+ Use the new cross-platform{' '}
+ DiscountOffer and SubscriptionOffer{' '}
+ types instead.
+
+
+
ProductAndroidOneTimePurchaseOfferDetail
@@ -253,8 +262,13 @@ function TypesAndroid() {
- SubscriptionOffer
+ SubscriptionOffer Deprecated
+
+ Deprecated: Use{' '}
+ SubscriptionOffer{' '}
+ (cross-platform) instead.
+
Offer details for subscription purchases:
diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx
index b6f9dbdc..74e392f9 100644
--- a/packages/docs/src/pages/docs/types/index.tsx
+++ b/packages/docs/src/pages/docs/types/index.tsx
@@ -53,8 +53,10 @@ const legacyAnchorRedirects: Record = {
'product-common': '/docs/types/product#product-common',
'product-platform': '/docs/types/product#product-platform',
'product-subscription': '/docs/types/product#product-subscription',
- 'subscription-product-common': '/docs/types/product#subscription-product-common',
- 'subscription-product-platform': '/docs/types/product#subscription-product-platform',
+ 'subscription-product-common':
+ '/docs/types/product#subscription-product-common',
+ 'subscription-product-platform':
+ '/docs/types/product#subscription-product-platform',
'unified-platform-types': '/docs/types/product#unified-platform-types',
'store-discriminators': '/docs/types/product#store-discriminators',
'union-types': '/docs/types/product#union-types',
@@ -66,9 +68,12 @@ const legacyAnchorRedirects: Record = {
'purchase-platform': '/docs/types/purchase#purchase-platform',
'renewal-info-ios': '/docs/types/purchase#renewal-info-ios',
'active-subscription': '/docs/types/purchase#active-subscription',
- 'active-subscription-common': '/docs/types/purchase#active-subscription-common',
- 'active-subscription-platform': '/docs/types/purchase#active-subscription-platform',
- 'active-subscription-example': '/docs/types/purchase#active-subscription-example',
+ 'active-subscription-common':
+ '/docs/types/purchase#active-subscription-common',
+ 'active-subscription-platform':
+ '/docs/types/purchase#active-subscription-platform',
+ 'active-subscription-example':
+ '/docs/types/purchase#active-subscription-example',
// Request types
'product-request': '/docs/types/request#product-request',
'product-request-fields': '/docs/types/request#product-request-fields',
@@ -82,9 +87,11 @@ const legacyAnchorRedirects: Record = {
'/docs/types/request#request-subscription-props-by-platforms',
'platform-specific-request-props':
'/docs/types/request#platform-specific-request-props',
- 'subscription-request-props': '/docs/types/request#subscription-request-props',
+ 'subscription-request-props':
+ '/docs/types/request#subscription-request-props',
// Alternative billing types
- 'alternative-billing-types': '/docs/types/alternative#alternative-billing-types',
+ 'alternative-billing-types':
+ '/docs/types/alternative#alternative-billing-types',
'alternative-billing-mode-android':
'/docs/types/alternative#alternative-billing-mode-android',
'init-connection-config': '/docs/types/alternative#init-connection-config',
@@ -93,7 +100,8 @@ const legacyAnchorRedirects: Record = {
'external-purchase-apis': '/docs/types/alternative#external-purchase-apis',
'external-purchase-types': '/docs/types/alternative#external-purchase-types',
'external-purchase-flow': '/docs/types/alternative#external-purchase-flow',
- 'external-purchase-example': '/docs/types/alternative#external-purchase-example',
+ 'external-purchase-example':
+ '/docs/types/alternative#external-purchase-example',
'external-purchase-requirements':
'/docs/types/alternative#external-purchase-requirements',
// Verification types
@@ -119,9 +127,13 @@ const legacyAnchorRedirects: Record = {
'/docs/types/verification#purchase-verification-provider',
'verify-purchase-with-provider-example':
'/docs/types/verification#verify-purchase-with-provider-example',
+ // Offer types (new standardized types)
+ 'discount-offer': '/docs/types/offer#discount-offer',
+ 'subscription-offer': '/docs/types/offer#subscription-offer',
+ 'discount-offer-type': '/docs/types/offer#discount-offer',
+ 'subscription-offer-type': '/docs/types/offer#subscription-offer',
// iOS types
- 'platform-specific-types': '/docs/types/ios#discount-offer',
- 'discount-offer': '/docs/types/ios#discount-offer',
+ 'platform-specific-types': '/docs/types/offer#discount-offer',
discount: '/docs/types/ios#discount',
'subscription-period-ios': '/docs/types/ios#subscription-period-ios',
'payment-mode': '/docs/types/ios#payment-mode',
@@ -133,8 +145,8 @@ const legacyAnchorRedirects: Record = {
'app-transaction-type-definition':
'/docs/types/ios#app-transaction-type-definition',
'app-transaction-example': '/docs/types/ios#app-transaction-example',
- // Android types
- 'subscription-offer': '/docs/types/android#subscription-offer',
+ // Android types (subscription-offer redirects to standardized offer page above)
+ 'subscription-offer-android': '/docs/types/android#subscription-offer',
'pricing-phase': '/docs/types/android#pricing-phase',
'recurrence-mode-values': '/docs/types/android#recurrence-mode-values',
'pricing-phases-android': '/docs/types/android#pricing-phases-android',
@@ -310,27 +322,52 @@ function TypesIndex() {
-
- Product: Product, SubscriptionProduct, Storefront
+
+ Product
+
+ : Product, SubscriptionProduct, Storefront
-
- Purchase: Purchase, ActiveSubscription
+
+ Purchase
+
+ : Purchase, ActiveSubscription
-
- Request: ProductRequest, RequestPurchaseProps
+
+ Offer
+
+ : DiscountOffer, SubscriptionOffer (cross-platform)
-
- Alternative Billing: External purchase and billing
- modes
+
+ Request
+
+ : ProductRequest, RequestPurchaseProps
-
- Verification: Purchase verification types
+
+ Alternative Billing
+
+ : External purchase and billing modes
-
- iOS: DiscountOffer, SubscriptionStatusIOS,
- AppTransaction
+
+ Verification
+
+ : Purchase verification types
-
- Android: SubscriptionOffer, PricingPhase
+
+ iOS
+
+ : SubscriptionStatusIOS, AppTransaction (platform-specific)
+
+ -
+
+ Android
+
+ : PricingPhase, PricingPhasesAndroid (platform-specific)
@@ -351,6 +388,12 @@ function TypesIndex() {
href="/docs/types/purchase"
count={2}
/>
+
Platform-Specific Types
- Types specific to iOS and Android platforms.
+
+ Types specific to iOS and Android platforms. Note: Discount/offer
+ types have been standardized in{' '}
+ Offer Types.
+
diff --git a/packages/docs/src/pages/docs/types/ios.tsx b/packages/docs/src/pages/docs/types/ios.tsx
index 20ae854f..b81f4c0a 100644
--- a/packages/docs/src/pages/docs/types/ios.tsx
+++ b/packages/docs/src/pages/docs/types/ios.tsx
@@ -12,9 +12,9 @@ function TypesIOS() {
iOS Types
@@ -25,23 +25,44 @@ function TypesIOS() {
+
+
+ Deprecation Notice: The iOS-specific discount and
+ offer types (DiscountOfferIOS, DiscountIOS,{' '}
+ SubscriptionOfferIOS) are deprecated. Use the new
+ cross-platform{' '}
+ DiscountOffer and SubscriptionOffer{' '}
+ types instead.
+
+
+
-
- DiscountOffer
+
+ DiscountOfferIOS Deprecated
+
+ Deprecated: Use{' '}
+ SubscriptionOffer{' '}
+ instead.
+
Used when requesting a purchase with a promotional offer. Generate
signature server-side.
@@ -89,9 +110,14 @@ function TypesIOS() {
-
- Discount
+
+ DiscountIOS Deprecated
+
+ Deprecated: Use{' '}
+ SubscriptionOffer{' '}
+ instead.
+
Discount info returned as part of product details:
diff --git a/packages/docs/src/pages/docs/types/offer.tsx b/packages/docs/src/pages/docs/types/offer.tsx
new file mode 100644
index 00000000..8063bb65
--- /dev/null
+++ b/packages/docs/src/pages/docs/types/offer.tsx
@@ -0,0 +1,1163 @@
+import AnchorLink from '../../../components/AnchorLink';
+import CodeBlock from '../../../components/CodeBlock';
+import LanguageTabs from '../../../components/LanguageTabs';
+import SEO from '../../../components/SEO';
+import TLDRBox from '../../../components/TLDRBox';
+import { useScrollToHash } from '../../../hooks/useScrollToHash';
+
+function TypesOffer() {
+ useScrollToHash();
+
+ return (
+
+
+
Discount & Subscription Offer Types
+
+ Standardized cross-platform types for handling discounts and
+ subscription offers. These types provide a unified interface while
+ preserving platform-specific functionality through suffixed fields.
+
+
+
+
+ -
+
+
DiscountOffer
+ {' '}
+ - One-time product discounts (Android 7.0+)
+
+ -
+
+
SubscriptionOffer
+ {' '}
+ - Subscription discounts (iOS & Android)
+
+ -
+ Platform-specific fields use
IOS or{' '}
+ Android suffix
+
+ -
+ Deprecated:{' '}
+
+ DiscountIOS, DiscountOfferIOS, SubscriptionOfferIOS,
+ ProductAndroidOneTimePurchaseOfferDetail,
+ ProductSubscriptionAndroidOfferDetails
+
+
+
+
+
+
+
+ Migration Note: The legacy platform-specific types
+ are now deprecated. Use these standardized types for new
+ implementations and migrate existing code when convenient.
+
+
+
+
+
+ DiscountOffer
+
+
+ Standardized type for one-time product discount offers. Currently
+ supported on Android (Google Play Billing Library 7.0+). iOS does not
+ support one-time purchase discounts.
+
+
+
+ Common Fields
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+
+ id
+ |
+
+ ID
+ |
+ Unique identifier for the offer |
+
+
+
+ displayPrice
+ |
+
+ String!
+ |
+ Formatted display price (e.g., "$4.99") |
+
+
+
+ price
+ |
+
+ Float!
+ |
+ Numeric price value |
+
+
+
+ currency
+ |
+
+ String!
+ |
+ Currency code (ISO 4217, e.g., "USD") |
+
+
+
+ type
+ |
+
+ DiscountOfferType!
+ |
+ Type of offer (Introductory, Promotional, OneTime) |
+
+
+
+
+
+ Android-Specific Fields
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+
+ offerTokenAndroid
+ |
+
+ String
+ |
+
+ Required for purchase. Pass to
+ requestPurchase()
+ |
+
+
+
+ offerTagsAndroid
+ |
+
+ [String!]
+ |
+ Tags associated with this offer |
+
+
+
+ fullPriceMicrosAndroid
+ |
+
+ String
+ |
+ Original price in micro-units (divide by 1,000,000) |
+
+
+
+ percentageDiscountAndroid
+ |
+
+ Int
+ |
+ Percentage discount (e.g., 33 for 33% off) |
+
+
+
+ discountAmountMicrosAndroid
+ |
+
+ String
+ |
+ Fixed discount amount in micro-units |
+
+
+
+ formattedDiscountAmountAndroid
+ |
+
+ String
+ |
+ Formatted discount amount (e.g., "$5.00 OFF") |
+
+
+
+ validTimeWindowAndroid
+ |
+
+ ValidTimeWindowAndroid
+ |
+ Time window for limited-time offers |
+
+
+
+ limitedQuantityInfoAndroid
+ |
+
+ LimitedQuantityInfoAndroid
+ |
+ Quantity limits for the offer |
+
+
+
+ preorderDetailsAndroid
+ |
+
+ PreorderDetailsAndroid
+ |
+ Pre-order details (Billing Library 8.1.0+) |
+
+
+
+ rentalDetailsAndroid
+ |
+
+ RentalDetailsAndroid
+ |
+ Rental offer details |
+
+
+
+
+
+ Type Definition
+
+
+ {{
+ typescript: (
+ {`interface DiscountOffer {
+ // Common fields
+ id: string | null;
+ displayPrice: string;
+ price: number;
+ currency: string;
+ type: DiscountOfferType;
+
+ // Android-specific fields
+ offerTokenAndroid?: string;
+ offerTagsAndroid?: string[];
+ fullPriceMicrosAndroid?: string;
+ percentageDiscountAndroid?: number;
+ discountAmountMicrosAndroid?: string;
+ formattedDiscountAmountAndroid?: string;
+ validTimeWindowAndroid?: ValidTimeWindowAndroid;
+ limitedQuantityInfoAndroid?: LimitedQuantityInfoAndroid;
+ preorderDetailsAndroid?: PreorderDetailsAndroid;
+ rentalDetailsAndroid?: RentalDetailsAndroid;
+}
+
+enum DiscountOfferType {
+ Introductory = 'Introductory',
+ Promotional = 'Promotional',
+ OneTime = 'OneTime',
+}`}
+ ),
+ swift: (
+ {`struct DiscountOffer: Codable {
+ // Common fields
+ let id: String?
+ let displayPrice: String
+ let price: Double
+ let currency: String
+ let type: DiscountOfferType
+
+ // Android-specific fields
+ let offerTokenAndroid: String?
+ let offerTagsAndroid: [String]?
+ let fullPriceMicrosAndroid: String?
+ let percentageDiscountAndroid: Int?
+ let discountAmountMicrosAndroid: String?
+ let formattedDiscountAmountAndroid: String?
+ let validTimeWindowAndroid: ValidTimeWindowAndroid?
+ let limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid?
+ let preorderDetailsAndroid: PreorderDetailsAndroid?
+ let rentalDetailsAndroid: RentalDetailsAndroid?
+}
+
+enum DiscountOfferType: String, Codable {
+ case introductory = "Introductory"
+ case promotional = "Promotional"
+ case oneTime = "OneTime"
+}`}
+ ),
+ kotlin: (
+ {`data class DiscountOffer(
+ // Common fields
+ val id: String?,
+ val displayPrice: String,
+ val price: Double,
+ val currency: String,
+ val type: DiscountOfferType,
+
+ // Android-specific fields
+ val offerTokenAndroid: String? = null,
+ val offerTagsAndroid: List? = null,
+ val fullPriceMicrosAndroid: String? = null,
+ val percentageDiscountAndroid: Int? = null,
+ val discountAmountMicrosAndroid: String? = null,
+ val formattedDiscountAmountAndroid: String? = null,
+ val validTimeWindowAndroid: ValidTimeWindowAndroid? = null,
+ val limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid? = null,
+ val preorderDetailsAndroid: PreorderDetailsAndroid? = null,
+ val rentalDetailsAndroid: RentalDetailsAndroid? = null
+)
+
+enum class DiscountOfferType {
+ Introductory,
+ Promotional,
+ OneTime
+}`}
+ ),
+ dart: (
+ {`class DiscountOffer {
+ // Common fields
+ final String? id;
+ final String displayPrice;
+ final double price;
+ final String currency;
+ final DiscountOfferType type;
+
+ // Android-specific fields
+ final String? offerTokenAndroid;
+ final List? offerTagsAndroid;
+ final String? fullPriceMicrosAndroid;
+ final int? percentageDiscountAndroid;
+ final String? discountAmountMicrosAndroid;
+ final String? formattedDiscountAmountAndroid;
+ final ValidTimeWindowAndroid? validTimeWindowAndroid;
+ final LimitedQuantityInfoAndroid? limitedQuantityInfoAndroid;
+ final PreorderDetailsAndroid? preorderDetailsAndroid;
+ final RentalDetailsAndroid? rentalDetailsAndroid;
+
+ DiscountOffer({
+ this.id,
+ required this.displayPrice,
+ required this.price,
+ required this.currency,
+ required this.type,
+ this.offerTokenAndroid,
+ this.offerTagsAndroid,
+ this.fullPriceMicrosAndroid,
+ this.percentageDiscountAndroid,
+ this.discountAmountMicrosAndroid,
+ this.formattedDiscountAmountAndroid,
+ this.validTimeWindowAndroid,
+ this.limitedQuantityInfoAndroid,
+ this.preorderDetailsAndroid,
+ this.rentalDetailsAndroid,
+ });
+}
+
+enum DiscountOfferType {
+ introductory,
+ promotional,
+ oneTime,
+}`}
+ ),
+ gdscript: (
+ {`class_name DiscountOffer
+
+# Common fields
+var id: String
+var display_price: String
+var price: float
+var currency: String
+var type: DiscountOfferType
+
+# Android-specific fields
+var offer_token_android: String
+var offer_tags_android: Array[String]
+var full_price_micros_android: String
+var percentage_discount_android: int
+var discount_amount_micros_android: String
+var formatted_discount_amount_android: String
+var valid_time_window_android: ValidTimeWindowAndroid
+var limited_quantity_info_android: LimitedQuantityInfoAndroid
+var preorder_details_android: PreorderDetailsAndroid
+var rental_details_android: RentalDetailsAndroid
+
+enum DiscountOfferType {
+ INTRODUCTORY,
+ PROMOTIONAL,
+ ONE_TIME
+}`}
+ ),
+ }}
+
+
+
+
+
+ SubscriptionOffer
+
+
+ Standardized type for subscription promotional offers. Supported on
+ both iOS (introductory and promotional offers) and Android (offer
+ tokens with pricing phases).
+
+
+
+ Common Fields
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+
+ id
+ |
+
+ ID!
+ |
+ Unique identifier for the offer |
+
+
+
+ displayPrice
+ |
+
+ String!
+ |
+ Formatted display price (e.g., "$9.99/month") |
+
+
+
+ price
+ |
+
+ Float!
+ |
+ Numeric price value |
+
+
+
+ currency
+ |
+
+ String
+ |
+ Currency code (ISO 4217) |
+
+
+
+ type
+ |
+
+ DiscountOfferType!
+ |
+ Introductory or Promotional |
+
+
+
+ period
+ |
+
+ SubscriptionPeriod
+ |
+ Subscription period (unit + value) |
+
+
+
+ periodCount
+ |
+
+ Int
+ |
+ Number of periods the offer applies |
+
+
+
+ paymentMode
+ |
+
+ PaymentMode
+ |
+ FreeTrial, PayAsYouGo, or PayUpFront |
+
+
+
+
+
+ iOS-Specific Fields
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+
+ keyIdentifierIOS
+ |
+
+ String
+ |
+ Key ID for server-side signature validation |
+
+
+
+ nonceIOS
+ |
+
+ String
+ |
+ Cryptographic nonce (UUID) for signature |
+
+
+
+ signatureIOS
+ |
+
+ String
+ |
+ Server-generated signature for validation |
+
+
+
+ timestampIOS
+ |
+
+ Float
+ |
+ Timestamp when signature was generated |
+
+
+
+ numberOfPeriodsIOS
+ |
+
+ Int
+ |
+ Number of billing periods for this discount |
+
+
+
+ localizedPriceIOS
+ |
+
+ String
+ |
+ Localized price string |
+
+
+
+
+
+ Android-Specific Fields
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+
+ basePlanIdAndroid
+ |
+
+ String
+ |
+ Base plan identifier |
+
+
+
+ offerTokenAndroid
+ |
+
+ String
+ |
+
+ Required for purchase. Pass to
+ requestPurchase()
+ |
+
+
+
+ offerTagsAndroid
+ |
+
+ [String!]
+ |
+ Tags associated with this offer |
+
+
+
+ pricingPhasesAndroid
+ |
+
+ PricingPhasesAndroid
+ |
+ Pricing phases (trial, intro, regular) |
+
+
+
+
+
+ Type Definition
+
+
+ {{
+ typescript: (
+ {`interface SubscriptionOffer {
+ // Common fields
+ id: string;
+ displayPrice: string;
+ price: number;
+ currency?: string;
+ type: DiscountOfferType;
+ period?: SubscriptionPeriod;
+ periodCount?: number;
+ paymentMode?: PaymentMode;
+
+ // iOS-specific fields
+ keyIdentifierIOS?: string;
+ nonceIOS?: string;
+ signatureIOS?: string;
+ timestampIOS?: number;
+ numberOfPeriodsIOS?: number;
+ localizedPriceIOS?: string;
+
+ // Android-specific fields
+ basePlanIdAndroid?: string;
+ offerTokenAndroid?: string;
+ offerTagsAndroid?: string[];
+ pricingPhasesAndroid?: PricingPhasesAndroid;
+}
+
+interface SubscriptionPeriod {
+ unit: SubscriptionPeriodUnit;
+ value: number;
+}
+
+enum SubscriptionPeriodUnit {
+ Day = 'Day',
+ Week = 'Week',
+ Month = 'Month',
+ Year = 'Year',
+ Unknown = 'Unknown',
+}
+
+enum PaymentMode {
+ FreeTrial = 'FreeTrial',
+ PayAsYouGo = 'PayAsYouGo',
+ PayUpFront = 'PayUpFront',
+ Unknown = 'Unknown',
+}`}
+ ),
+ swift: (
+ {`struct SubscriptionOffer: Codable {
+ // Common fields
+ let id: String
+ let displayPrice: String
+ let price: Double
+ let currency: String?
+ let type: DiscountOfferType
+ let period: SubscriptionPeriod?
+ let periodCount: Int?
+ let paymentMode: PaymentMode?
+
+ // iOS-specific fields
+ let keyIdentifierIOS: String?
+ let nonceIOS: String?
+ let signatureIOS: String?
+ let timestampIOS: Double?
+ let numberOfPeriodsIOS: Int?
+ let localizedPriceIOS: String?
+
+ // Android-specific fields
+ let basePlanIdAndroid: String?
+ let offerTokenAndroid: String?
+ let offerTagsAndroid: [String]?
+ let pricingPhasesAndroid: PricingPhasesAndroid?
+}
+
+struct SubscriptionPeriod: Codable {
+ let unit: SubscriptionPeriodUnit
+ let value: Int
+}
+
+enum SubscriptionPeriodUnit: String, Codable {
+ case day = "Day"
+ case week = "Week"
+ case month = "Month"
+ case year = "Year"
+ case unknown = "Unknown"
+}
+
+enum PaymentMode: String, Codable {
+ case freeTrial = "FreeTrial"
+ case payAsYouGo = "PayAsYouGo"
+ case payUpFront = "PayUpFront"
+ case unknown = "Unknown"
+}`}
+ ),
+ kotlin: (
+ {`data class SubscriptionOffer(
+ // Common fields
+ val id: String,
+ val displayPrice: String,
+ val price: Double,
+ val currency: String? = null,
+ val type: DiscountOfferType,
+ val period: SubscriptionPeriod? = null,
+ val periodCount: Int? = null,
+ val paymentMode: PaymentMode? = null,
+
+ // iOS-specific fields
+ val keyIdentifierIOS: String? = null,
+ val nonceIOS: String? = null,
+ val signatureIOS: String? = null,
+ val timestampIOS: Double? = null,
+ val numberOfPeriodsIOS: Int? = null,
+ val localizedPriceIOS: String? = null,
+
+ // Android-specific fields
+ val basePlanIdAndroid: String? = null,
+ val offerTokenAndroid: String? = null,
+ val offerTagsAndroid: List? = null,
+ val pricingPhasesAndroid: PricingPhasesAndroid? = null
+)
+
+data class SubscriptionPeriod(
+ val unit: SubscriptionPeriodUnit,
+ val value: Int
+)
+
+enum class SubscriptionPeriodUnit {
+ Day, Week, Month, Year, Unknown
+}
+
+enum class PaymentMode {
+ FreeTrial, PayAsYouGo, PayUpFront, Unknown
+}`}
+ ),
+ dart: (
+ {`class SubscriptionOffer {
+ // Common fields
+ final String id;
+ final String displayPrice;
+ final double price;
+ final String? currency;
+ final DiscountOfferType type;
+ final SubscriptionPeriod? period;
+ final int? periodCount;
+ final PaymentMode? paymentMode;
+
+ // iOS-specific fields
+ final String? keyIdentifierIOS;
+ final String? nonceIOS;
+ final String? signatureIOS;
+ final double? timestampIOS;
+ final int? numberOfPeriodsIOS;
+ final String? localizedPriceIOS;
+
+ // Android-specific fields
+ final String? basePlanIdAndroid;
+ final String? offerTokenAndroid;
+ final List? offerTagsAndroid;
+ final PricingPhasesAndroid? pricingPhasesAndroid;
+
+ SubscriptionOffer({
+ required this.id,
+ required this.displayPrice,
+ required this.price,
+ this.currency,
+ required this.type,
+ this.period,
+ this.periodCount,
+ this.paymentMode,
+ this.keyIdentifierIOS,
+ this.nonceIOS,
+ this.signatureIOS,
+ this.timestampIOS,
+ this.numberOfPeriodsIOS,
+ this.localizedPriceIOS,
+ this.basePlanIdAndroid,
+ this.offerTokenAndroid,
+ this.offerTagsAndroid,
+ this.pricingPhasesAndroid,
+ });
+}
+
+class SubscriptionPeriod {
+ final SubscriptionPeriodUnit unit;
+ final int value;
+
+ SubscriptionPeriod({required this.unit, required this.value});
+}
+
+enum SubscriptionPeriodUnit { day, week, month, year, unknown }
+
+enum PaymentMode { freeTrial, payAsYouGo, payUpFront, unknown }`}
+ ),
+ gdscript: (
+ {`class_name SubscriptionOffer
+
+# Common fields
+var id: String
+var display_price: String
+var price: float
+var currency: String
+var type: DiscountOfferType
+var period: SubscriptionPeriod
+var period_count: int
+var payment_mode: PaymentMode
+
+# iOS-specific fields
+var key_identifier_ios: String
+var nonce_ios: String
+var signature_ios: String
+var timestamp_ios: float
+var number_of_periods_ios: int
+var localized_price_ios: String
+
+# Android-specific fields
+var base_plan_id_android: String
+var offer_token_android: String
+var offer_tags_android: Array[String]
+var pricing_phases_android: PricingPhasesAndroid
+
+class SubscriptionPeriod:
+ var unit: SubscriptionPeriodUnit
+ var value: int
+
+enum SubscriptionPeriodUnit { DAY, WEEK, MONTH, YEAR, UNKNOWN }
+enum PaymentMode { FREE_TRIAL, PAY_AS_YOU_GO, PAY_UP_FRONT, UNKNOWN }`}
+ ),
+ }}
+
+
+
+
+
+ Usage Example
+
+
+ Access standardized offers from products and use platform-specific
+ fields when needed:
+
+
+
+ {{
+ typescript: (
+ {`import { fetchProducts, requestPurchase, Product } from 'expo-iap';
+
+const products = await fetchProducts({
+ skus: ['premium_feature', 'premium_subscription'],
+});
+
+for (const product of products) {
+ // Access standardized discount offers (one-time products)
+ const discountOffers = product.discountOffers;
+ if (discountOffers && discountOffers.length > 0) {
+ const offer = discountOffers[0];
+ console.log('Discount:', offer.displayPrice);
+ console.log('Original:', offer.fullPriceMicrosAndroid);
+ console.log('Percentage off:', offer.percentageDiscountAndroid);
+ }
+
+ // Access standardized subscription offers
+ const subscriptionOffers = product.subscriptionOffers;
+ if (subscriptionOffers && subscriptionOffers.length > 0) {
+ const offer = subscriptionOffers[0];
+ console.log('Subscription offer:', offer.displayPrice);
+ console.log('Period:', offer.period?.unit, offer.period?.value);
+ console.log('Payment mode:', offer.paymentMode);
+
+ // Platform-specific: Android needs offerToken
+ if (offer.offerTokenAndroid) {
+ await requestPurchase({
+ request: {
+ google: {
+ skus: [product.id],
+ subscriptionOffers: [{
+ sku: product.id,
+ offerToken: offer.offerTokenAndroid,
+ }],
+ },
+ },
+ type: 'subs',
+ });
+ }
+
+ // Platform-specific: iOS needs server-side signature for promotional offers
+ if (offer.signatureIOS) {
+ await requestPurchase({
+ request: {
+ apple: {
+ sku: product.id,
+ withOffer: {
+ identifier: offer.id,
+ keyIdentifier: offer.keyIdentifierIOS!,
+ nonce: offer.nonceIOS!,
+ signature: offer.signatureIOS,
+ timestamp: offer.timestampIOS!,
+ },
+ },
+ },
+ type: 'subs',
+ });
+ }
+ }
+}`}
+ ),
+ kotlin: (
+ {`import dev.hyo.openiap.OpenIapModule
+import dev.hyo.openiap.types.*
+
+val products = openIapModule.fetchProducts(
+ skus = listOf("premium_feature", "premium_subscription"),
+ type = ProductQueryType.All
+)
+
+products.forEach { product ->
+ // Access standardized discount offers (one-time products)
+ product.discountOffers?.forEach { offer ->
+ println("Discount: \${offer.displayPrice}")
+ println("Original: \${offer.fullPriceMicrosAndroid}")
+ println("Percentage off: \${offer.percentageDiscountAndroid}")
+ }
+
+ // Access standardized subscription offers
+ product.subscriptionOffers?.forEach { offer ->
+ println("Subscription offer: \${offer.displayPrice}")
+ println("Period: \${offer.period?.unit} \${offer.period?.value}")
+ println("Payment mode: \${offer.paymentMode}")
+
+ // Use offerToken for Android purchases
+ offer.offerTokenAndroid?.let { token ->
+ openIapModule.requestPurchase(
+ sku = product.id,
+ subscriptionOffers = listOf(
+ SubscriptionOfferAndroid(
+ sku = product.id,
+ offerToken = token
+ )
+ )
+ )
+ }
+ }
+}`}
+ ),
+ swift: (
+ {`import OpenIap
+
+let products = try await OpenIapModule.shared.fetchProducts(
+ skus: ["premium_feature", "premium_subscription"]
+)
+
+for product in products {
+ // Access standardized subscription offers
+ if let offers = product.subscriptionOffers {
+ for offer in offers {
+ print("Subscription offer: \\(offer.displayPrice)")
+ if let period = offer.period {
+ print("Period: \\(period.unit) \\(period.value)")
+ }
+ print("Payment mode: \\(offer.paymentMode ?? .unknown)")
+
+ // iOS promotional offers require server-side signature
+ if let signature = offer.signatureIOS,
+ let keyId = offer.keyIdentifierIOS,
+ let nonce = offer.nonceIOS,
+ let timestamp = offer.timestampIOS {
+ try await OpenIapModule.shared.requestPurchase(
+ sku: product.id,
+ withOffer: DiscountOfferInputIOS(
+ identifier: offer.id,
+ keyIdentifier: keyId,
+ nonce: nonce,
+ signature: signature,
+ timestamp: timestamp
+ )
+ )
+ }
+ }
+ }
+}`}
+ ),
+ gdscript: (
+ {`var request = ProductRequest.new()
+request.skus = ["premium_feature", "premium_subscription"]
+request.type = ProductQueryType.ALL
+var products = await iap.fetch_products(request)
+
+for product in products:
+ # Access standardized discount offers (one-time products)
+ if product.discount_offers:
+ for offer in product.discount_offers:
+ print("Discount: %s" % offer.display_price)
+ print("Original: %s" % offer.full_price_micros_android)
+ print("Percentage off: %d" % offer.percentage_discount_android)
+
+ # Access standardized subscription offers
+ if product.subscription_offers:
+ for offer in product.subscription_offers:
+ print("Subscription offer: %s" % offer.display_price)
+ if offer.period:
+ print("Period: %s %d" % [offer.period.unit, offer.period.value])
+ print("Payment mode: %s" % offer.payment_mode)
+
+ # Use offerToken for Android purchases
+ if offer.offer_token_android:
+ var props = RequestPurchaseProps.new()
+ props.request = RequestSubscriptionPropsByPlatforms.new()
+ props.request.google = RequestSubscriptionAndroidProps.new()
+ props.request.google.skus = [product.id]
+ props.request.google.subscription_offers = [{
+ "sku": product.id,
+ "offerToken": offer.offer_token_android
+ }]
+ props.type = ProductQueryType.SUBS
+ await iap.request_purchase(props)`}
+ ),
+ }}
+
+
+
+
+
+ Migration Guide
+
+
+ Migrate from deprecated platform-specific types to the new
+ standardized types:
+
+
+
+
+
+ | Deprecated Type |
+ New Type |
+ Notes |
+
+
+
+
+
+ DiscountIOS
+ |
+
+ SubscriptionOffer
+ |
+ Use iOS-suffixed fields for platform-specific data |
+
+
+
+ DiscountOfferIOS
+ |
+
+ SubscriptionOffer
+ |
+
+ Signature fields: keyIdentifierIOS,{' '}
+ nonceIOS, etc.
+ |
+
+
+
+ SubscriptionOfferIOS
+ |
+
+ SubscriptionOffer
+ |
+
+ Period info in common period field
+ |
+
+
+
+ ProductAndroidOneTimePurchaseOfferDetail
+ |
+
+ DiscountOffer
+ |
+ Use Android-suffixed fields |
+
+
+
+ ProductSubscriptionAndroidOfferDetails
+ |
+
+ SubscriptionOffer
+ |
+
+ pricingPhasesAndroid for detailed phases
+ |
+
+
+
+ subscriptionInfoIOS
+ |
+
+ subscriptionOffers
+ |
+ Field on Product types |
+
+
+
+ oneTimePurchaseOfferDetailsAndroid
+ |
+
+ discountOffers
+ |
+ Field on Product types |
+
+
+
+ subscriptionOfferDetailsAndroid
+ |
+
+ subscriptionOffers
+ |
+ Field on Product types |
+
+
+
+
+
+
+ Backward Compatibility: The deprecated types and
+ fields are still available but will be removed in a future major
+ version. Plan your migration accordingly.
+
+
+
+
+ );
+}
+
+export default TypesOffer;
diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt
index 51e03387..00b29e88 100644
--- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt
+++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt
@@ -11,6 +11,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.hyo.martie.models.AppColors
+import dev.hyo.openiap.DiscountOffer
import dev.hyo.openiap.ProductAndroid
import dev.hyo.openiap.ProductType
import java.util.Locale
@@ -111,21 +112,29 @@ fun ProductCard(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- // Check for discount information
- val firstOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull()
- val discountInfo = firstOffer?.discountDisplayInfo
- val hasDiscount = discountInfo != null
+ // Check for discount using new standardized DiscountOffer type (preferred)
+ val standardizedDiscount = product.discountOffers?.firstOrNull()
+ // Fall back to deprecated type for backward compatibility
+ val legacyOffer = product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull()
+ val discountInfo = legacyOffer?.discountDisplayInfo
- if (hasDiscount && firstOffer?.fullPriceMicros != null) {
+ // Use standardized type if available, otherwise fall back to legacy
+ val hasDiscount = standardizedDiscount != null || discountInfo != null
+
+ if (hasDiscount) {
// Show original price with strikethrough
- val fullPriceMicros = firstOffer.fullPriceMicros?.toLongOrNull() ?: 0L
- val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0
- Text(
- "${firstOffer.priceCurrencyCode} ${String.format("%.2f", fullPrice)}",
- style = MaterialTheme.typography.bodyMedium,
- color = AppColors.textSecondary,
- textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough
- )
+ val fullPriceMicros = standardizedDiscount?.fullPriceMicrosAndroid?.toLongOrNull()
+ ?: legacyOffer?.fullPriceMicros?.toLongOrNull()
+ if (fullPriceMicros != null) {
+ val fullPrice = fullPriceMicros.toDouble() / 1_000_000.0
+ val currencyCode = standardizedDiscount?.currency ?: legacyOffer?.priceCurrencyCode ?: ""
+ Text(
+ "$currencyCode ${String.format(Locale.getDefault(), "%.2f", fullPrice)}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = AppColors.textSecondary,
+ textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough
+ )
+ }
}
Text(
@@ -135,11 +144,19 @@ fun ProductCard(
color = if (hasDiscount) AppColors.success else AppColors.primary
)
- // Show discount badge
+ // Show discount badge using standardized type or legacy
if (hasDiscount) {
- val discountText = when {
- discountInfo?.percentageDiscount != null -> "${discountInfo.percentageDiscount}% OFF"
- discountInfo?.discountAmount != null -> "${discountInfo.discountAmount?.formattedDiscountAmount} OFF"
+ val discountText: String = when {
+ // Prefer standardized DiscountOffer fields
+ standardizedDiscount?.percentageDiscountAndroid != null ->
+ "${standardizedDiscount.percentageDiscountAndroid}% OFF"
+ standardizedDiscount?.formattedDiscountAmountAndroid != null ->
+ standardizedDiscount.formattedDiscountAmountAndroid!!
+ // Fall back to legacy discountDisplayInfo
+ discountInfo?.percentageDiscount != null ->
+ "${discountInfo.percentageDiscount}% OFF"
+ discountInfo?.discountAmount?.formattedDiscountAmount != null ->
+ discountInfo.discountAmount!!.formattedDiscountAmount
else -> "SALE"
}
Surface(
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt
index d1189002..03e2bf36 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt
@@ -47,6 +47,7 @@ internal object HorizonBillingConverters {
currency = currency,
debugDescription = description,
description = description,
+ discountOffers = null, // Horizon doesn't support discount offers yet
displayName = name,
displayPrice = displayPrice,
id = productId,
@@ -55,6 +56,7 @@ internal object HorizonBillingConverters {
platform = IapPlatform.Android,
price = priceAmountMicros.toDouble() / 1_000_000.0,
subscriptionOfferDetailsAndroid = null,
+ subscriptionOffers = null, // Horizon doesn't support standardized offers yet
title = title,
type = ProductType.InApp
)
@@ -111,6 +113,7 @@ internal object HorizonBillingConverters {
currency = currency,
debugDescription = description,
description = description,
+ discountOffers = null, // Horizon doesn't support discount offers yet
displayName = name,
displayPrice = displayPrice,
id = productId,
@@ -119,6 +122,7 @@ internal object HorizonBillingConverters {
platform = IapPlatform.Android,
price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0),
subscriptionOfferDetailsAndroid = pricingDetails,
+ subscriptionOffers = emptyList(), // Horizon doesn't support standardized offers yet
title = title,
type = ProductType.Subs
)
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 4ec222eb..6fe83696 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
@@ -137,6 +137,42 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Discount offer type enumeration.
+ * Categorizes the type of discount or promotional offer.
+ */
+public enum class DiscountOfferType(val rawValue: String) {
+ /**
+ * Introductory offer for new subscribers (first-time purchase discount)
+ */
+ Introductory("introductory"),
+ /**
+ * Promotional offer for existing or returning subscribers
+ */
+ Promotional("promotional"),
+ /**
+ * One-time product discount (Android only, Google Play Billing 7.0+)
+ */
+ OneTime("one-time");
+
+ companion object {
+ fun fromJson(value: String): DiscountOfferType = when (value) {
+ "introductory" -> DiscountOfferType.Introductory
+ "Introductory" -> DiscountOfferType.Introductory
+ "INTRODUCTORY" -> DiscountOfferType.Introductory
+ "promotional" -> DiscountOfferType.Promotional
+ "Promotional" -> DiscountOfferType.Promotional
+ "PROMOTIONAL" -> DiscountOfferType.Promotional
+ "one-time" -> DiscountOfferType.OneTime
+ "OneTime" -> DiscountOfferType.OneTime
+ "ONE_TIME" -> DiscountOfferType.OneTime
+ else -> throw IllegalArgumentException("Unknown DiscountOfferType value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class ErrorCode(val rawValue: String) {
Unknown("unknown"),
UserCancelled("user-cancelled"),
@@ -493,6 +529,49 @@ public enum class IapStore(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Payment mode for subscription offers.
+ * Determines how the user pays during the offer period.
+ */
+public enum class PaymentMode(val rawValue: String) {
+ /**
+ * Free trial period - no charge during offer
+ */
+ FreeTrial("free-trial"),
+ /**
+ * Pay each period at reduced price
+ */
+ PayAsYouGo("pay-as-you-go"),
+ /**
+ * Pay full discounted amount upfront
+ */
+ PayUpFront("pay-up-front"),
+ /**
+ * Unknown or unspecified payment mode
+ */
+ Unknown("unknown");
+
+ companion object {
+ fun fromJson(value: String): PaymentMode = when (value) {
+ "free-trial" -> PaymentMode.FreeTrial
+ "FreeTrial" -> PaymentMode.FreeTrial
+ "FREE_TRIAL" -> PaymentMode.FreeTrial
+ "pay-as-you-go" -> PaymentMode.PayAsYouGo
+ "PayAsYouGo" -> PaymentMode.PayAsYouGo
+ "PAY_AS_YOU_GO" -> PaymentMode.PayAsYouGo
+ "pay-up-front" -> PaymentMode.PayUpFront
+ "PayUpFront" -> PaymentMode.PayUpFront
+ "PAY_UP_FRONT" -> PaymentMode.PayUpFront
+ "unknown" -> PaymentMode.Unknown
+ "Unknown" -> PaymentMode.Unknown
+ "UNKNOWN" -> PaymentMode.Unknown
+ else -> throw IllegalArgumentException("Unknown PaymentMode value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class PaymentModeIOS(val rawValue: String) {
Empty("empty"),
FreeTrial("free-trial"),
@@ -653,6 +732,40 @@ public enum class SubscriptionPeriodIOS(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Subscription period unit for cross-platform use.
+ */
+public enum class SubscriptionPeriodUnit(val rawValue: String) {
+ Day("day"),
+ Week("week"),
+ Month("month"),
+ Year("year"),
+ Unknown("unknown");
+
+ companion object {
+ fun fromJson(value: String): SubscriptionPeriodUnit = when (value) {
+ "day" -> SubscriptionPeriodUnit.Day
+ "Day" -> SubscriptionPeriodUnit.Day
+ "DAY" -> SubscriptionPeriodUnit.Day
+ "week" -> SubscriptionPeriodUnit.Week
+ "Week" -> SubscriptionPeriodUnit.Week
+ "WEEK" -> SubscriptionPeriodUnit.Week
+ "month" -> SubscriptionPeriodUnit.Month
+ "Month" -> SubscriptionPeriodUnit.Month
+ "MONTH" -> SubscriptionPeriodUnit.Month
+ "year" -> SubscriptionPeriodUnit.Year
+ "Year" -> SubscriptionPeriodUnit.Year
+ "YEAR" -> SubscriptionPeriodUnit.Year
+ "unknown" -> SubscriptionPeriodUnit.Unknown
+ "Unknown" -> SubscriptionPeriodUnit.Unknown
+ "UNKNOWN" -> SubscriptionPeriodUnit.Unknown
+ else -> throw IllegalArgumentException("Unknown SubscriptionPeriodUnit value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
/**
* Replacement mode for subscription changes (Android)
* These modes determine how the subscription replacement affects billing.
@@ -1039,6 +1152,11 @@ public data class DiscountDisplayInfoAndroid(
)
}
+/**
+ * Discount information returned from the store.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class DiscountIOS(
val identifier: String,
val localizedPrice: String? = null,
@@ -1078,6 +1196,135 @@ public data class DiscountIOS(
)
}
+/**
+ * Standardized one-time product discount offer.
+ * Provides a unified interface for one-time purchase discounts across platforms.
+ *
+ * Currently supported on Android (Google Play Billing 7.0+).
+ * iOS does not support one-time purchase discounts in the same way.
+ *
+ * @see https://openiap.dev/docs/features/discount
+ */
+public data class DiscountOffer(
+ /**
+ * Currency code (ISO 4217, e.g., "USD")
+ */
+ val currency: String,
+ /**
+ * [Android] Fixed discount amount in micro-units.
+ * Only present for fixed amount discounts.
+ */
+ val discountAmountMicrosAndroid: String? = null,
+ /**
+ * Formatted display price string (e.g., "$4.99")
+ */
+ val displayPrice: String,
+ /**
+ * [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ */
+ val formattedDiscountAmountAndroid: String? = null,
+ /**
+ * [Android] Original full price in micro-units before discount.
+ * Divide by 1,000,000 to get the actual price.
+ * Use for displaying strikethrough original price.
+ */
+ val fullPriceMicrosAndroid: String? = null,
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Not applicable (one-time discounts not supported)
+ * - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ */
+ val id: String? = null,
+ /**
+ * [Android] Limited quantity information.
+ * Contains maximumQuantity and remainingQuantity.
+ */
+ val limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid? = null,
+ /**
+ * [Android] List of tags associated with this offer.
+ */
+ val offerTagsAndroid: List? = null,
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ val offerTokenAndroid: String? = null,
+ /**
+ * [Android] Percentage discount (e.g., 33 for 33% off).
+ * Only present for percentage-based discounts.
+ */
+ val percentageDiscountAndroid: Int? = null,
+ /**
+ * [Android] Pre-order details if this is a pre-order offer.
+ * Available in Google Play Billing Library 8.1.0+
+ */
+ val preorderDetailsAndroid: PreorderDetailsAndroid? = null,
+ /**
+ * Numeric price value
+ */
+ val price: Double,
+ /**
+ * [Android] Rental details if this is a rental offer.
+ */
+ val rentalDetailsAndroid: RentalDetailsAndroid? = null,
+ /**
+ * Type of discount offer
+ */
+ val type: DiscountOfferType,
+ /**
+ * [Android] Valid time window for the offer.
+ * Contains startTimeMillis and endTimeMillis.
+ */
+ val validTimeWindowAndroid: ValidTimeWindowAndroid? = null
+) {
+
+ companion object {
+ fun fromJson(json: Map): DiscountOffer {
+ return DiscountOffer(
+ currency = json["currency"] as? String ?: "",
+ discountAmountMicrosAndroid = json["discountAmountMicrosAndroid"] as? String,
+ displayPrice = json["displayPrice"] as? String ?: "",
+ formattedDiscountAmountAndroid = json["formattedDiscountAmountAndroid"] as? String,
+ fullPriceMicrosAndroid = json["fullPriceMicrosAndroid"] as? String,
+ id = json["id"] as? String,
+ limitedQuantityInfoAndroid = (json["limitedQuantityInfoAndroid"] as? Map)?.let { LimitedQuantityInfoAndroid.fromJson(it) },
+ offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String },
+ offerTokenAndroid = json["offerTokenAndroid"] as? String,
+ percentageDiscountAndroid = (json["percentageDiscountAndroid"] as? Number)?.toInt(),
+ preorderDetailsAndroid = (json["preorderDetailsAndroid"] as? Map)?.let { PreorderDetailsAndroid.fromJson(it) },
+ price = (json["price"] as? Number)?.toDouble() ?: 0.0,
+ rentalDetailsAndroid = (json["rentalDetailsAndroid"] as? Map)?.let { RentalDetailsAndroid.fromJson(it) },
+ type = (json["type"] as? String)?.let { DiscountOfferType.fromJson(it) } ?: DiscountOfferType.Introductory,
+ validTimeWindowAndroid = (json["validTimeWindowAndroid"] as? Map)?.let { ValidTimeWindowAndroid.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "DiscountOffer",
+ "currency" to currency,
+ "discountAmountMicrosAndroid" to discountAmountMicrosAndroid,
+ "displayPrice" to displayPrice,
+ "formattedDiscountAmountAndroid" to formattedDiscountAmountAndroid,
+ "fullPriceMicrosAndroid" to fullPriceMicrosAndroid,
+ "id" to id,
+ "limitedQuantityInfoAndroid" to limitedQuantityInfoAndroid?.toJson(),
+ "offerTagsAndroid" to offerTagsAndroid?.map { it },
+ "offerTokenAndroid" to offerTokenAndroid,
+ "percentageDiscountAndroid" to percentageDiscountAndroid,
+ "preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(),
+ "price" to price,
+ "rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(),
+ "type" to type.toJson(),
+ "validTimeWindowAndroid" to validTimeWindowAndroid?.toJson(),
+ )
+}
+
+/**
+ * iOS DiscountOffer (output type).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class DiscountOfferIOS(
/**
* Discount identifier
@@ -1386,6 +1633,12 @@ public data class ProductAndroid(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ val discountOffers: List? = null,
override val displayName: String? = null,
override val displayPrice: String,
override val id: String,
@@ -1393,11 +1646,21 @@ public data class ProductAndroid(
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
*/
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionOfferDetailsAndroid: List? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
override val title: String,
override val type: ProductType = ProductType.InApp
) : ProductCommon, Product {
@@ -1408,6 +1671,7 @@ public data class ProductAndroid(
currency = json["currency"] as? String ?: "",
debugDescription = json["debugDescription"] as? String,
description = json["description"] as? String ?: "",
+ discountOffers = (json["discountOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DiscountOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DiscountOffer") },
displayName = json["displayName"] as? String,
displayPrice = json["displayPrice"] as? String ?: "",
id = json["id"] as? String ?: "",
@@ -1416,6 +1680,7 @@ public data class ProductAndroid(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
)
@@ -1427,6 +1692,7 @@ public data class ProductAndroid(
"currency" to currency,
"debugDescription" to debugDescription,
"description" to description,
+ "discountOffers" to discountOffers?.map { it.toJson() },
"displayName" to displayName,
"displayPrice" to displayPrice,
"id" to id,
@@ -1435,14 +1701,17 @@ public data class ProductAndroid(
"platform" to platform.toJson(),
"price" to price,
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() },
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
)
}
/**
- * One-time purchase offer details (Android)
+ * One-time purchase offer details (Android).
* Available in Google Play Billing Library 7.0+
+ * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#discount-offer
*/
public data class ProductAndroidOneTimePurchaseOfferDetail(
/**
@@ -1537,7 +1806,17 @@ public data class ProductIOS(
val jsonRepresentationIOS: String,
override val platform: IapPlatform = IapPlatform.Ios,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionInfoIOS: SubscriptionInfoIOS? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * Note: iOS does not support one-time product discounts.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
override val title: String,
override val type: ProductType = ProductType.InApp,
val typeIOS: ProductTypeIOS
@@ -1558,6 +1837,7 @@ public data class ProductIOS(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionInfoIOS = (json["subscriptionInfoIOS"] as? Map)?.let { SubscriptionInfoIOS.fromJson(it) },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
typeIOS = (json["typeIOS"] as? String)?.let { ProductTypeIOS.fromJson(it) } ?: ProductTypeIOS.Consumable,
@@ -1579,6 +1859,7 @@ public data class ProductIOS(
"platform" to platform.toJson(),
"price" to price,
"subscriptionInfoIOS" to subscriptionInfoIOS?.toJson(),
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
"typeIOS" to typeIOS.toJson(),
@@ -1589,6 +1870,12 @@ public data class ProductSubscriptionAndroid(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ val discountOffers: List? = null,
override val displayName: String? = null,
override val displayPrice: String,
override val id: String,
@@ -1596,11 +1883,21 @@ public data class ProductSubscriptionAndroid(
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
*/
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionOfferDetailsAndroid: List,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List,
override val title: String,
override val type: ProductType = ProductType.Subs
) : ProductCommon, ProductSubscription {
@@ -1611,6 +1908,7 @@ public data class ProductSubscriptionAndroid(
currency = json["currency"] as? String ?: "",
debugDescription = json["debugDescription"] as? String,
description = json["description"] as? String ?: "",
+ discountOffers = (json["discountOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DiscountOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DiscountOffer") },
displayName = json["displayName"] as? String,
displayPrice = json["displayPrice"] as? String ?: "",
id = json["id"] as? String ?: "",
@@ -1619,6 +1917,7 @@ public data class ProductSubscriptionAndroid(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(),
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(),
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
)
@@ -1630,6 +1929,7 @@ public data class ProductSubscriptionAndroid(
"currency" to currency,
"debugDescription" to debugDescription,
"description" to description,
+ "discountOffers" to discountOffers?.map { it.toJson() },
"displayName" to displayName,
"displayPrice" to displayPrice,
"id" to id,
@@ -1638,11 +1938,17 @@ public data class ProductSubscriptionAndroid(
"platform" to platform.toJson(),
"price" to price,
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() },
+ "subscriptionOffers" to subscriptionOffers.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
)
}
+/**
+ * Subscription offer details (Android).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class ProductSubscriptionAndroidOfferDetails(
val basePlanId: String,
val offerId: String? = null,
@@ -1677,6 +1983,9 @@ public data class ProductSubscriptionIOS(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val discountsIOS: List? = null,
override val displayName: String? = null,
val displayNameIOS: String,
@@ -1691,7 +2000,16 @@ public data class ProductSubscriptionIOS(
val jsonRepresentationIOS: String,
override val platform: IapPlatform = IapPlatform.Ios,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionInfoIOS: SubscriptionInfoIOS? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
val subscriptionPeriodNumberIOS: String? = null,
val subscriptionPeriodUnitIOS: SubscriptionPeriodIOS? = null,
override val title: String,
@@ -1720,6 +2038,7 @@ public data class ProductSubscriptionIOS(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionInfoIOS = (json["subscriptionInfoIOS"] as? Map)?.let { SubscriptionInfoIOS.fromJson(it) },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
subscriptionPeriodNumberIOS = json["subscriptionPeriodNumberIOS"] as? String,
subscriptionPeriodUnitIOS = (json["subscriptionPeriodUnitIOS"] as? String)?.let { SubscriptionPeriodIOS.fromJson(it) },
title = json["title"] as? String ?: "",
@@ -1749,6 +2068,7 @@ public data class ProductSubscriptionIOS(
"platform" to platform.toJson(),
"price" to price,
"subscriptionInfoIOS" to subscriptionInfoIOS?.toJson(),
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"subscriptionPeriodNumberIOS" to subscriptionPeriodNumberIOS,
"subscriptionPeriodUnitIOS" to subscriptionPeriodUnitIOS?.toJson(),
"title" to title,
@@ -2212,6 +2532,154 @@ public data class SubscriptionInfoIOS(
)
}
+/**
+ * Standardized subscription discount/promotional offer.
+ * Provides a unified interface for subscription offers across iOS and Android.
+ *
+ * Both platforms support subscription offers with different implementations:
+ * - iOS: Introductory offers, promotional offers with server-side signatures
+ * - Android: Offer tokens with pricing phases
+ *
+ * @see https://openiap.dev/docs/types/ios#discount-offer
+ * @see https://openiap.dev/docs/types/android#subscription-offer
+ */
+public data class SubscriptionOffer(
+ /**
+ * [Android] Base plan identifier.
+ * Identifies which base plan this offer belongs to.
+ */
+ val basePlanIdAndroid: String? = null,
+ /**
+ * Currency code (ISO 4217, e.g., "USD")
+ */
+ val currency: String? = null,
+ /**
+ * Formatted display price string (e.g., "$9.99/month")
+ */
+ val displayPrice: String,
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Discount identifier from App Store Connect
+ * - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ */
+ val id: String,
+ /**
+ * [iOS] Key identifier for signature validation.
+ * Used with server-side signature generation for promotional offers.
+ */
+ val keyIdentifierIOS: String? = null,
+ /**
+ * [iOS] Localized price string.
+ */
+ val localizedPriceIOS: String? = null,
+ /**
+ * [iOS] Cryptographic nonce (UUID) for signature validation.
+ * Must be generated server-side for each purchase attempt.
+ */
+ val nonceIOS: String? = null,
+ /**
+ * [iOS] Number of billing periods for this discount.
+ */
+ val numberOfPeriodsIOS: Int? = null,
+ /**
+ * [Android] List of tags associated with this offer.
+ */
+ val offerTagsAndroid: List? = null,
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ val offerTokenAndroid: String? = null,
+ /**
+ * Payment mode during the offer period
+ */
+ val paymentMode: PaymentMode? = null,
+ /**
+ * Subscription period for this offer
+ */
+ val period: SubscriptionPeriod? = null,
+ /**
+ * Number of periods the offer applies
+ */
+ val periodCount: Int? = null,
+ /**
+ * Numeric price value
+ */
+ val price: Double,
+ /**
+ * [Android] Pricing phases for this subscription offer.
+ * Contains detailed pricing information for each phase (trial, intro, regular).
+ */
+ val pricingPhasesAndroid: PricingPhasesAndroid? = null,
+ /**
+ * [iOS] Server-generated signature for promotional offer validation.
+ * Required when applying promotional offers on iOS.
+ */
+ val signatureIOS: String? = null,
+ /**
+ * [iOS] Timestamp when the signature was generated.
+ * Used for signature validation.
+ */
+ val timestampIOS: Double? = null,
+ /**
+ * Type of subscription offer (Introductory or Promotional)
+ */
+ val type: DiscountOfferType
+) {
+
+ companion object {
+ fun fromJson(json: Map): SubscriptionOffer {
+ return SubscriptionOffer(
+ basePlanIdAndroid = json["basePlanIdAndroid"] as? String,
+ currency = json["currency"] as? String,
+ displayPrice = json["displayPrice"] as? String ?: "",
+ id = json["id"] as? String ?: "",
+ keyIdentifierIOS = json["keyIdentifierIOS"] as? String,
+ localizedPriceIOS = json["localizedPriceIOS"] as? String,
+ nonceIOS = json["nonceIOS"] as? String,
+ numberOfPeriodsIOS = (json["numberOfPeriodsIOS"] as? Number)?.toInt(),
+ offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String },
+ offerTokenAndroid = json["offerTokenAndroid"] as? String,
+ paymentMode = (json["paymentMode"] as? String)?.let { PaymentMode.fromJson(it) },
+ period = (json["period"] as? Map)?.let { SubscriptionPeriod.fromJson(it) },
+ periodCount = (json["periodCount"] as? Number)?.toInt(),
+ price = (json["price"] as? Number)?.toDouble() ?: 0.0,
+ pricingPhasesAndroid = (json["pricingPhasesAndroid"] as? Map)?.let { PricingPhasesAndroid.fromJson(it) },
+ signatureIOS = json["signatureIOS"] as? String,
+ timestampIOS = (json["timestampIOS"] as? Number)?.toDouble(),
+ type = (json["type"] as? String)?.let { DiscountOfferType.fromJson(it) } ?: DiscountOfferType.Introductory,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "SubscriptionOffer",
+ "basePlanIdAndroid" to basePlanIdAndroid,
+ "currency" to currency,
+ "displayPrice" to displayPrice,
+ "id" to id,
+ "keyIdentifierIOS" to keyIdentifierIOS,
+ "localizedPriceIOS" to localizedPriceIOS,
+ "nonceIOS" to nonceIOS,
+ "numberOfPeriodsIOS" to numberOfPeriodsIOS,
+ "offerTagsAndroid" to offerTagsAndroid?.map { it },
+ "offerTokenAndroid" to offerTokenAndroid,
+ "paymentMode" to paymentMode?.toJson(),
+ "period" to period?.toJson(),
+ "periodCount" to periodCount,
+ "price" to price,
+ "pricingPhasesAndroid" to pricingPhasesAndroid?.toJson(),
+ "signatureIOS" to signatureIOS,
+ "timestampIOS" to timestampIOS,
+ "type" to type.toJson(),
+ )
+}
+
+/**
+ * iOS subscription offer details.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class SubscriptionOfferIOS(
val displayPrice: String,
val id: String,
@@ -2248,6 +2716,36 @@ public data class SubscriptionOfferIOS(
)
}
+/**
+ * Subscription period value combining unit and count.
+ */
+public data class SubscriptionPeriod(
+ /**
+ * The period unit (day, week, month, year)
+ */
+ val unit: SubscriptionPeriodUnit,
+ /**
+ * The number of units (e.g., 1 for monthly, 3 for quarterly)
+ */
+ val value: Int
+) {
+
+ companion object {
+ fun fromJson(json: Map): SubscriptionPeriod {
+ return SubscriptionPeriod(
+ unit = (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } ?: SubscriptionPeriodUnit.Day,
+ value = (json["value"] as? Number)?.toInt() ?: 0,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "SubscriptionPeriod",
+ "unit" to unit.toJson(),
+ "value" to value,
+ )
+}
+
public data class SubscriptionPeriodValueIOS(
val unit: SubscriptionPeriodIOS,
val value: Int
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
index 6da3ea39..c3569b08 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
@@ -3,9 +3,12 @@ package dev.hyo.openiap.utils
import dev.hyo.openiap.ActiveSubscription
import dev.hyo.openiap.DiscountAmountAndroid
import dev.hyo.openiap.DiscountDisplayInfoAndroid
+import dev.hyo.openiap.DiscountOffer
+import dev.hyo.openiap.DiscountOfferType
import dev.hyo.openiap.IapPlatform
import dev.hyo.openiap.IapStore
import dev.hyo.openiap.LimitedQuantityInfoAndroid
+import dev.hyo.openiap.PaymentMode
import dev.hyo.openiap.PricingPhaseAndroid
import dev.hyo.openiap.PricingPhasesAndroid
import dev.hyo.openiap.Product
@@ -20,6 +23,9 @@ import dev.hyo.openiap.PurchaseAndroid
import dev.hyo.openiap.PurchaseInput
import dev.hyo.openiap.PurchaseState
import dev.hyo.openiap.RentalDetailsAndroid
+import dev.hyo.openiap.SubscriptionOffer
+import dev.hyo.openiap.SubscriptionPeriod
+import dev.hyo.openiap.SubscriptionPeriodUnit
import dev.hyo.openiap.ValidTimeWindowAndroid
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.ProductDetails
@@ -94,6 +100,135 @@ internal object BillingConverters {
)
}
+ /**
+ * Converts a ProductDetails.OneTimePurchaseOfferDetails to standardized DiscountOffer.
+ * This is the new cross-platform type for one-time product discounts.
+ */
+ private fun ProductDetails.OneTimePurchaseOfferDetails.toDiscountOffer(): DiscountOffer {
+ val discountInfo = discountDisplayInfo
+
+ return DiscountOffer(
+ id = runCatching { offerId }.getOrNull(),
+ displayPrice = formattedPrice,
+ price = priceAmountMicros.toDouble() / 1_000_000.0,
+ currency = priceCurrencyCode,
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = offerToken,
+ offerTagsAndroid = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() },
+ fullPriceMicrosAndroid = runCatching { fullPriceMicros?.toString() }.getOrNull(),
+ percentageDiscountAndroid = runCatching { discountInfo?.percentageDiscount }.getOrNull(),
+ discountAmountMicrosAndroid = runCatching { discountInfo?.discountAmount?.discountAmountMicros?.toString() }.getOrNull(),
+ formattedDiscountAmountAndroid = runCatching { discountInfo?.discountAmount?.formattedDiscountAmount }.getOrNull(),
+ validTimeWindowAndroid = validTimeWindow?.let { window ->
+ ValidTimeWindowAndroid(
+ startTimeMillis = window.startTimeMillis.toString(),
+ endTimeMillis = window.endTimeMillis.toString()
+ )
+ },
+ limitedQuantityInfoAndroid = limitedQuantityInfo?.let { info ->
+ LimitedQuantityInfoAndroid(
+ maximumQuantity = info.maximumQuantity,
+ remainingQuantity = info.remainingQuantity
+ )
+ },
+ preorderDetailsAndroid = runCatching { preorderDetails }?.getOrNull()?.let { details ->
+ PreorderDetailsAndroid(
+ preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(),
+ preorderReleaseTimeMillis = details.preorderReleaseTimeMillis.toString()
+ )
+ },
+ rentalDetailsAndroid = runCatching { rentalDetails }?.getOrNull()?.let { details ->
+ RentalDetailsAndroid(
+ rentalPeriod = details.rentalPeriod,
+ rentalExpirationPeriod = runCatching { details.rentalExpirationPeriod }.getOrNull()
+ )
+ }
+ )
+ }
+
+ /**
+ * Parses ISO 8601 duration format (e.g., "P1W", "P1M", "P1Y") to SubscriptionPeriod.
+ */
+ private fun parseBillingPeriod(billingPeriod: String): SubscriptionPeriod? {
+ if (billingPeriod.isEmpty()) return null
+
+ val regex = Regex("P(\\d+)([DWMY])")
+ val match = regex.find(billingPeriod) ?: return null
+
+ val value = match.groupValues[1].toIntOrNull() ?: return null
+ val unit = when (match.groupValues[2]) {
+ "D" -> SubscriptionPeriodUnit.Day
+ "W" -> SubscriptionPeriodUnit.Week
+ "M" -> SubscriptionPeriodUnit.Month
+ "Y" -> SubscriptionPeriodUnit.Year
+ else -> SubscriptionPeriodUnit.Unknown
+ }
+
+ return SubscriptionPeriod(unit = unit, value = value)
+ }
+
+ /**
+ * Determines PaymentMode from recurrenceMode and price.
+ * recurrenceMode: 1 = INFINITE_RECURRING, 2 = FINITE_RECURRING, 3 = NON_RECURRING
+ */
+ private fun determinePaymentMode(recurrenceMode: Int, priceAmountMicros: Long): PaymentMode {
+ return when {
+ priceAmountMicros == 0L -> PaymentMode.FreeTrial
+ recurrenceMode == 3 -> PaymentMode.PayUpFront
+ recurrenceMode == 2 -> PaymentMode.PayAsYouGo
+ recurrenceMode == 1 -> PaymentMode.PayAsYouGo
+ else -> PaymentMode.Unknown
+ }
+ }
+
+ /**
+ * Converts a ProductDetails.SubscriptionOfferDetails to standardized SubscriptionOffer.
+ * This is the new cross-platform type for subscription offers.
+ */
+ private fun ProductDetails.SubscriptionOfferDetails.toSubscriptionOffer(): SubscriptionOffer {
+ val firstPhase = pricingPhases.pricingPhaseList.firstOrNull()
+ val displayPrice = firstPhase?.formattedPrice.orEmpty()
+ val currency = firstPhase?.priceCurrencyCode.orEmpty()
+ val price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0) ?: 0.0
+
+ val period = firstPhase?.billingPeriod?.let { parseBillingPeriod(it) }
+ val paymentMode = firstPhase?.let {
+ determinePaymentMode(it.recurrenceMode, it.priceAmountMicros)
+ }
+
+ val offerType = when {
+ offerId == null && firstPhase?.priceAmountMicros == 0L -> DiscountOfferType.Introductory
+ offerId != null -> DiscountOfferType.Promotional
+ else -> DiscountOfferType.Introductory
+ }
+
+ return SubscriptionOffer(
+ id = offerId ?: basePlanId,
+ displayPrice = displayPrice,
+ price = price,
+ currency = currency,
+ type = offerType,
+ period = period,
+ periodCount = firstPhase?.billingCycleCount,
+ paymentMode = paymentMode,
+ basePlanIdAndroid = basePlanId,
+ offerTokenAndroid = offerToken,
+ offerTagsAndroid = offerTags,
+ pricingPhasesAndroid = PricingPhasesAndroid(
+ pricingPhaseList = pricingPhases.pricingPhaseList.map { phase ->
+ PricingPhaseAndroid(
+ billingCycleCount = phase.billingCycleCount,
+ billingPeriod = phase.billingPeriod,
+ formattedPrice = phase.formattedPrice,
+ priceAmountMicros = phase.priceAmountMicros.toString(),
+ priceCurrencyCode = phase.priceCurrencyCode,
+ recurrenceMode = phase.recurrenceMode
+ )
+ }
+ )
+ )
+ }
+
fun ProductDetails.toInAppProduct(): ProductAndroid {
// Get all offers using getOneTimePurchaseOfferDetailsList() for discount support
val allOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty()
@@ -104,7 +239,7 @@ internal object BillingConverters {
val currency = offer?.priceCurrencyCode.orEmpty()
val priceAmountMicros = offer?.priceAmountMicros ?: 0L
- // Convert all offers to the list format
+ // Convert all offers to the list format (deprecated)
val offerDetailsList = if (allOffers.isNotEmpty()) {
allOffers.map { it.toOfferDetail() }
} else {
@@ -112,10 +247,18 @@ internal object BillingConverters {
offer?.let { listOf(it.toOfferDetail()) }
}
+ // Convert to standardized DiscountOffer (new cross-platform type)
+ val discountOffers = if (allOffers.isNotEmpty()) {
+ allOffers.map { it.toDiscountOffer() }
+ } else {
+ offer?.let { listOf(it.toDiscountOffer()) }
+ }
+
return ProductAndroid(
currency = currency,
debugDescription = description,
description = description,
+ discountOffers = discountOffers,
displayName = name,
displayPrice = displayPrice,
id = productId,
@@ -124,6 +267,7 @@ internal object BillingConverters {
platform = IapPlatform.Android,
price = priceAmountMicros.toDouble() / 1_000_000.0,
subscriptionOfferDetailsAndroid = null,
+ subscriptionOffers = null,
title = title,
type = ProductType.InApp
)
@@ -134,6 +278,8 @@ internal object BillingConverters {
val firstPhase = offers.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()
val displayPrice = firstPhase?.formattedPrice.orEmpty()
val currency = firstPhase?.priceCurrencyCode.orEmpty()
+
+ // Convert to deprecated format (for backwards compatibility)
val pricingDetails = offers.map { offer ->
ProductSubscriptionAndroidOfferDetails(
basePlanId = offer.basePlanId,
@@ -155,6 +301,9 @@ internal object BillingConverters {
)
}
+ // Convert to standardized SubscriptionOffer (new cross-platform type)
+ val subscriptionOffers = offers.map { it.toSubscriptionOffer() }
+
// Get all one-time offers for subscriptions that may have them
val allOneTimeOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty()
val oneTimeOfferDetailsList = if (allOneTimeOffers.isNotEmpty()) {
@@ -163,10 +312,18 @@ internal object BillingConverters {
oneTimePurchaseOfferDetails?.let { listOf(it.toOfferDetail()) }
}
+ // Convert to standardized DiscountOffer (new cross-platform type)
+ val discountOffers = if (allOneTimeOffers.isNotEmpty()) {
+ allOneTimeOffers.map { it.toDiscountOffer() }
+ } else {
+ oneTimePurchaseOfferDetails?.let { listOf(it.toDiscountOffer()) }
+ }
+
return ProductSubscriptionAndroid(
currency = currency,
debugDescription = description,
description = description,
+ discountOffers = discountOffers,
displayName = name,
displayPrice = displayPrice,
id = productId,
@@ -175,6 +332,7 @@ internal object BillingConverters {
platform = IapPlatform.Android,
price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0),
subscriptionOfferDetailsAndroid = pricingDetails,
+ subscriptionOffers = subscriptionOffers,
title = title,
type = ProductType.Subs
)
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt
new file mode 100644
index 00000000..90233ba3
--- /dev/null
+++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt
@@ -0,0 +1,407 @@
+package dev.hyo.openiap
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class StandardizedOfferTypesTest {
+
+ // MARK: - DiscountOfferType Tests
+
+ @Test
+ fun `DiscountOfferType has correct raw values`() {
+ assertEquals("introductory", DiscountOfferType.Introductory.rawValue)
+ assertEquals("promotional", DiscountOfferType.Promotional.rawValue)
+ assertEquals("one-time", DiscountOfferType.OneTime.rawValue)
+ }
+
+ @Test
+ fun `DiscountOfferType fromJson handles supported formats`() {
+ // Kebab-case (standard)
+ assertEquals(DiscountOfferType.Introductory, DiscountOfferType.fromJson("introductory"))
+ assertEquals(DiscountOfferType.Promotional, DiscountOfferType.fromJson("promotional"))
+ assertEquals(DiscountOfferType.OneTime, DiscountOfferType.fromJson("one-time"))
+
+ // PascalCase
+ assertEquals(DiscountOfferType.Introductory, DiscountOfferType.fromJson("Introductory"))
+ assertEquals(DiscountOfferType.Promotional, DiscountOfferType.fromJson("Promotional"))
+ assertEquals(DiscountOfferType.OneTime, DiscountOfferType.fromJson("OneTime"))
+ }
+
+ @Test
+ fun `DiscountOfferType toJson returns correct value`() {
+ assertEquals("introductory", DiscountOfferType.Introductory.toJson())
+ assertEquals("promotional", DiscountOfferType.Promotional.toJson())
+ assertEquals("one-time", DiscountOfferType.OneTime.toJson())
+ }
+
+ // MARK: - PaymentMode Tests
+
+ @Test
+ fun `PaymentMode has correct raw values`() {
+ assertEquals("free-trial", PaymentMode.FreeTrial.rawValue)
+ assertEquals("pay-as-you-go", PaymentMode.PayAsYouGo.rawValue)
+ assertEquals("pay-up-front", PaymentMode.PayUpFront.rawValue)
+ assertEquals("unknown", PaymentMode.Unknown.rawValue)
+ }
+
+ @Test
+ fun `PaymentMode fromJson handles supported formats`() {
+ // Kebab-case (standard)
+ assertEquals(PaymentMode.FreeTrial, PaymentMode.fromJson("free-trial"))
+ assertEquals(PaymentMode.PayAsYouGo, PaymentMode.fromJson("pay-as-you-go"))
+ assertEquals(PaymentMode.PayUpFront, PaymentMode.fromJson("pay-up-front"))
+ assertEquals(PaymentMode.Unknown, PaymentMode.fromJson("unknown"))
+
+ // PascalCase
+ assertEquals(PaymentMode.FreeTrial, PaymentMode.fromJson("FreeTrial"))
+ assertEquals(PaymentMode.PayAsYouGo, PaymentMode.fromJson("PayAsYouGo"))
+ assertEquals(PaymentMode.PayUpFront, PaymentMode.fromJson("PayUpFront"))
+ assertEquals(PaymentMode.Unknown, PaymentMode.fromJson("Unknown"))
+ }
+
+ // MARK: - SubscriptionPeriodUnit Tests
+
+ @Test
+ fun `SubscriptionPeriodUnit has correct raw values`() {
+ assertEquals("day", SubscriptionPeriodUnit.Day.rawValue)
+ assertEquals("week", SubscriptionPeriodUnit.Week.rawValue)
+ assertEquals("month", SubscriptionPeriodUnit.Month.rawValue)
+ assertEquals("year", SubscriptionPeriodUnit.Year.rawValue)
+ assertEquals("unknown", SubscriptionPeriodUnit.Unknown.rawValue)
+ }
+
+ @Test
+ fun `SubscriptionPeriodUnit fromJson handles supported formats`() {
+ // Lowercase (standard)
+ assertEquals(SubscriptionPeriodUnit.Day, SubscriptionPeriodUnit.fromJson("day"))
+ assertEquals(SubscriptionPeriodUnit.Week, SubscriptionPeriodUnit.fromJson("week"))
+ assertEquals(SubscriptionPeriodUnit.Month, SubscriptionPeriodUnit.fromJson("month"))
+ assertEquals(SubscriptionPeriodUnit.Year, SubscriptionPeriodUnit.fromJson("year"))
+ assertEquals(SubscriptionPeriodUnit.Unknown, SubscriptionPeriodUnit.fromJson("unknown"))
+
+ // PascalCase
+ assertEquals(SubscriptionPeriodUnit.Day, SubscriptionPeriodUnit.fromJson("Day"))
+ assertEquals(SubscriptionPeriodUnit.Week, SubscriptionPeriodUnit.fromJson("Week"))
+ assertEquals(SubscriptionPeriodUnit.Month, SubscriptionPeriodUnit.fromJson("Month"))
+ assertEquals(SubscriptionPeriodUnit.Year, SubscriptionPeriodUnit.fromJson("Year"))
+ assertEquals(SubscriptionPeriodUnit.Unknown, SubscriptionPeriodUnit.fromJson("Unknown"))
+ }
+
+ // MARK: - SubscriptionPeriod Tests
+
+ @Test
+ fun `SubscriptionPeriod creation and toJson`() {
+ val period = SubscriptionPeriod(unit = SubscriptionPeriodUnit.Month, value = 3)
+
+ assertEquals(SubscriptionPeriodUnit.Month, period.unit)
+ assertEquals(3, period.value)
+
+ val json = period.toJson()
+ assertEquals("month", json["unit"])
+ assertEquals(3, json["value"])
+ }
+
+ @Test
+ fun `SubscriptionPeriod fromJson`() {
+ val json = mapOf(
+ "unit" to "week",
+ "value" to 2
+ )
+
+ val period = SubscriptionPeriod.fromJson(json)
+ assertEquals(SubscriptionPeriodUnit.Week, period.unit)
+ assertEquals(2, period.value)
+ }
+
+ // MARK: - DiscountOffer Tests
+
+ @Test
+ fun `DiscountOffer creation with Android-specific fields`() {
+ val offer = DiscountOffer(
+ id = "summer_sale_2025",
+ displayPrice = "$4.99",
+ price = 4.99,
+ currency = "USD",
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = "token_abc123",
+ offerTagsAndroid = listOf("summer", "sale"),
+ fullPriceMicrosAndroid = "9990000",
+ percentageDiscountAndroid = 50,
+ discountAmountMicrosAndroid = "4990000",
+ formattedDiscountAmountAndroid = "$5.00 OFF",
+ validTimeWindowAndroid = ValidTimeWindowAndroid(
+ startTimeMillis = "1704067200000",
+ endTimeMillis = "1735689599000"
+ ),
+ limitedQuantityInfoAndroid = LimitedQuantityInfoAndroid(
+ maximumQuantity = 3,
+ remainingQuantity = 2
+ )
+ )
+
+ assertEquals("summer_sale_2025", offer.id)
+ assertEquals("$4.99", offer.displayPrice)
+ assertEquals(4.99, offer.price, 0.01)
+ assertEquals("USD", offer.currency)
+ assertEquals(DiscountOfferType.OneTime, offer.type)
+ assertEquals("token_abc123", offer.offerTokenAndroid)
+ assertEquals(listOf("summer", "sale"), offer.offerTagsAndroid)
+ assertEquals("9990000", offer.fullPriceMicrosAndroid)
+ assertEquals(50, offer.percentageDiscountAndroid)
+ assertEquals("4990000", offer.discountAmountMicrosAndroid)
+ assertEquals("$5.00 OFF", offer.formattedDiscountAmountAndroid)
+ assertEquals("1704067200000", offer.validTimeWindowAndroid?.startTimeMillis)
+ assertEquals(3, offer.limitedQuantityInfoAndroid?.maximumQuantity)
+ }
+
+ @Test
+ fun `DiscountOffer toJson serializes correctly`() {
+ val offer = DiscountOffer(
+ id = "promo_001",
+ displayPrice = "$2.99",
+ price = 2.99,
+ currency = "USD",
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = "token_xyz"
+ )
+
+ val json = offer.toJson()
+ assertEquals("promo_001", json["id"])
+ assertEquals("$2.99", json["displayPrice"])
+ assertEquals(2.99, json["price"])
+ assertEquals("USD", json["currency"])
+ assertEquals("one-time", json["type"])
+ assertEquals("token_xyz", json["offerTokenAndroid"])
+ }
+
+ @Test
+ fun `DiscountOffer fromJson deserializes correctly`() {
+ val json = mapOf(
+ "id" to "discount_100",
+ "displayPrice" to "$1.99",
+ "price" to 1.99,
+ "currency" to "EUR",
+ "type" to "one-time",
+ "offerTokenAndroid" to "token_123",
+ "percentageDiscountAndroid" to 33
+ )
+
+ val offer = DiscountOffer.fromJson(json)
+ assertEquals("discount_100", offer.id)
+ assertEquals("$1.99", offer.displayPrice)
+ assertEquals(1.99, offer.price, 0.01)
+ assertEquals("EUR", offer.currency)
+ assertEquals(DiscountOfferType.OneTime, offer.type)
+ assertEquals("token_123", offer.offerTokenAndroid)
+ assertEquals(33, offer.percentageDiscountAndroid)
+ }
+
+ // MARK: - SubscriptionOffer Tests
+
+ @Test
+ fun `SubscriptionOffer creation with cross-platform fields`() {
+ val offer = SubscriptionOffer(
+ id = "intro_monthly",
+ displayPrice = "$0.00",
+ price = 0.0,
+ currency = "USD",
+ type = DiscountOfferType.Introductory,
+ period = SubscriptionPeriod(unit = SubscriptionPeriodUnit.Week, value = 1),
+ periodCount = 1,
+ paymentMode = PaymentMode.FreeTrial,
+ basePlanIdAndroid = "monthly_base",
+ offerTokenAndroid = "offer_token_abc",
+ offerTagsAndroid = listOf("trial")
+ )
+
+ assertEquals("intro_monthly", offer.id)
+ assertEquals("$0.00", offer.displayPrice)
+ assertEquals(0.0, offer.price, 0.01)
+ assertEquals(DiscountOfferType.Introductory, offer.type)
+ assertEquals(PaymentMode.FreeTrial, offer.paymentMode)
+ assertEquals(SubscriptionPeriodUnit.Week, offer.period?.unit)
+ assertEquals(1, offer.period?.value)
+ assertEquals(1, offer.periodCount)
+ assertEquals("monthly_base", offer.basePlanIdAndroid)
+ assertEquals("offer_token_abc", offer.offerTokenAndroid)
+ }
+
+ @Test
+ fun `SubscriptionOffer creation with iOS-specific fields`() {
+ val offer = SubscriptionOffer(
+ id = "promo_ios",
+ displayPrice = "$4.99",
+ price = 4.99,
+ currency = "USD",
+ type = DiscountOfferType.Promotional,
+ keyIdentifierIOS = "key_123",
+ nonceIOS = "uuid-nonce-456",
+ signatureIOS = "signature_base64",
+ timestampIOS = 1704067200000.0,
+ numberOfPeriodsIOS = 3,
+ localizedPriceIOS = "$4.99"
+ )
+
+ assertEquals("promo_ios", offer.id)
+ assertEquals("key_123", offer.keyIdentifierIOS)
+ assertEquals("uuid-nonce-456", offer.nonceIOS)
+ assertEquals("signature_base64", offer.signatureIOS)
+ assertEquals(1704067200000.0, offer.timestampIOS)
+ assertEquals(3, offer.numberOfPeriodsIOS)
+ assertEquals("$4.99", offer.localizedPriceIOS)
+ }
+
+ @Test
+ fun `SubscriptionOffer toJson serializes correctly`() {
+ val pricingPhases = PricingPhasesAndroid(
+ pricingPhaseList = listOf(
+ PricingPhaseAndroid(
+ billingCycleCount = 0,
+ billingPeriod = "P1W",
+ formattedPrice = "$0.00",
+ priceAmountMicros = "0",
+ priceCurrencyCode = "USD",
+ recurrenceMode = 3
+ ),
+ PricingPhaseAndroid(
+ billingCycleCount = 0,
+ billingPeriod = "P1M",
+ formattedPrice = "$9.99",
+ priceAmountMicros = "9990000",
+ priceCurrencyCode = "USD",
+ recurrenceMode = 1
+ )
+ )
+ )
+
+ val offer = SubscriptionOffer(
+ id = "sub_offer",
+ displayPrice = "$0.00",
+ price = 0.0,
+ currency = "USD",
+ type = DiscountOfferType.Introductory,
+ paymentMode = PaymentMode.FreeTrial,
+ period = SubscriptionPeriod(unit = SubscriptionPeriodUnit.Week, value = 1),
+ periodCount = 1,
+ basePlanIdAndroid = "base_monthly",
+ offerTokenAndroid = "token_sub",
+ pricingPhasesAndroid = pricingPhases
+ )
+
+ val json = offer.toJson()
+ assertEquals("sub_offer", json["id"])
+ assertEquals("$0.00", json["displayPrice"])
+ assertEquals(0.0, json["price"])
+ assertEquals("introductory", json["type"])
+ assertEquals("free-trial", json["paymentMode"])
+ assertEquals("base_monthly", json["basePlanIdAndroid"])
+ assertEquals("token_sub", json["offerTokenAndroid"])
+
+ @Suppress("UNCHECKED_CAST")
+ val periodJson = json["period"] as Map
+ assertEquals("week", periodJson["unit"])
+ assertEquals(1, periodJson["value"])
+ }
+
+ @Test
+ fun `SubscriptionOffer fromJson deserializes correctly`() {
+ val json = mapOf(
+ "id" to "parsed_offer",
+ "displayPrice" to "$2.99",
+ "price" to 2.99,
+ "currency" to "USD",
+ "type" to "promotional",
+ "paymentMode" to "pay-as-you-go",
+ "basePlanIdAndroid" to "yearly_base",
+ "offerTokenAndroid" to "parsed_token",
+ "period" to mapOf(
+ "unit" to "month",
+ "value" to 3
+ ),
+ "periodCount" to 3
+ )
+
+ val offer = SubscriptionOffer.fromJson(json)
+ assertEquals("parsed_offer", offer.id)
+ assertEquals("$2.99", offer.displayPrice)
+ assertEquals(2.99, offer.price, 0.01)
+ assertEquals(DiscountOfferType.Promotional, offer.type)
+ assertEquals(PaymentMode.PayAsYouGo, offer.paymentMode)
+ assertEquals("yearly_base", offer.basePlanIdAndroid)
+ assertEquals("parsed_token", offer.offerTokenAndroid)
+ assertEquals(SubscriptionPeriodUnit.Month, offer.period?.unit)
+ assertEquals(3, offer.period?.value)
+ assertEquals(3, offer.periodCount)
+ }
+
+ // MARK: - ProductAndroid Integration Tests
+
+ @Test
+ fun `ProductAndroid can hold discountOffers and subscriptionOffers`() {
+ val discountOffer = DiscountOffer(
+ id = "discount_001",
+ displayPrice = "$4.99",
+ price = 4.99,
+ currency = "USD",
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = "disc_token"
+ )
+
+ val product = ProductAndroid(
+ id = "test_product",
+ title = "Test Product",
+ description = "A test product",
+ displayName = "Test",
+ displayPrice = "$9.99",
+ price = 9.99,
+ currency = "USD",
+ platform = IapPlatform.Android,
+ type = ProductType.InApp,
+ nameAndroid = "Test Product",
+ discountOffers = listOf(discountOffer),
+ subscriptionOffers = null,
+ oneTimePurchaseOfferDetailsAndroid = null,
+ subscriptionOfferDetailsAndroid = null
+ )
+
+ assertEquals(1, product.discountOffers?.size)
+ assertEquals("discount_001", product.discountOffers?.first()?.id)
+ assertNull(product.subscriptionOffers)
+ }
+
+ @Test
+ fun `ProductSubscriptionAndroid can hold subscriptionOffers`() {
+ val subscriptionOffer = SubscriptionOffer(
+ id = "sub_intro",
+ displayPrice = "$0.00",
+ price = 0.0,
+ currency = "USD",
+ type = DiscountOfferType.Introductory,
+ paymentMode = PaymentMode.FreeTrial,
+ basePlanIdAndroid = "monthly",
+ offerTokenAndroid = "sub_token"
+ )
+
+ val product = ProductSubscriptionAndroid(
+ id = "subscription_product",
+ title = "Premium Subscription",
+ description = "Monthly premium subscription",
+ displayName = "Premium",
+ displayPrice = "$9.99",
+ price = 9.99,
+ currency = "USD",
+ platform = IapPlatform.Android,
+ type = ProductType.Subs,
+ nameAndroid = "Premium Subscription",
+ discountOffers = null,
+ subscriptionOffers = listOf(subscriptionOffer),
+ oneTimePurchaseOfferDetailsAndroid = null,
+ subscriptionOfferDetailsAndroid = emptyList()
+ )
+
+ assertEquals(1, product.subscriptionOffers.size)
+ assertEquals("sub_intro", product.subscriptionOffers.first().id)
+ assertEquals(PaymentMode.FreeTrial, product.subscriptionOffers.first().paymentMode)
+ }
+}
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index 77eabe26..c631dfff 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -136,6 +136,42 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Discount offer type enumeration.
+ * Categorizes the type of discount or promotional offer.
+ */
+public enum class DiscountOfferType(val rawValue: String) {
+ /**
+ * Introductory offer for new subscribers (first-time purchase discount)
+ */
+ Introductory("introductory"),
+ /**
+ * Promotional offer for existing or returning subscribers
+ */
+ Promotional("promotional"),
+ /**
+ * One-time product discount (Android only, Google Play Billing 7.0+)
+ */
+ OneTime("one-time")
+
+ companion object {
+ fun fromJson(value: String): DiscountOfferType = when (value) {
+ "introductory" -> DiscountOfferType.Introductory
+ "INTRODUCTORY" -> DiscountOfferType.Introductory
+ "Introductory" -> DiscountOfferType.Introductory
+ "promotional" -> DiscountOfferType.Promotional
+ "PROMOTIONAL" -> DiscountOfferType.Promotional
+ "Promotional" -> DiscountOfferType.Promotional
+ "one-time" -> DiscountOfferType.OneTime
+ "ONE_TIME" -> DiscountOfferType.OneTime
+ "OneTime" -> DiscountOfferType.OneTime
+ else -> throw IllegalArgumentException("Unknown DiscountOfferType value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class ErrorCode(val rawValue: String) {
Unknown("unknown"),
UserCancelled("user-cancelled"),
@@ -539,6 +575,49 @@ public enum class IapStore(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Payment mode for subscription offers.
+ * Determines how the user pays during the offer period.
+ */
+public enum class PaymentMode(val rawValue: String) {
+ /**
+ * Free trial period - no charge during offer
+ */
+ FreeTrial("free-trial"),
+ /**
+ * Pay each period at reduced price
+ */
+ PayAsYouGo("pay-as-you-go"),
+ /**
+ * Pay full discounted amount upfront
+ */
+ PayUpFront("pay-up-front"),
+ /**
+ * Unknown or unspecified payment mode
+ */
+ Unknown("unknown")
+
+ companion object {
+ fun fromJson(value: String): PaymentMode = when (value) {
+ "free-trial" -> PaymentMode.FreeTrial
+ "FREE_TRIAL" -> PaymentMode.FreeTrial
+ "FreeTrial" -> PaymentMode.FreeTrial
+ "pay-as-you-go" -> PaymentMode.PayAsYouGo
+ "PAY_AS_YOU_GO" -> PaymentMode.PayAsYouGo
+ "PayAsYouGo" -> PaymentMode.PayAsYouGo
+ "pay-up-front" -> PaymentMode.PayUpFront
+ "PAY_UP_FRONT" -> PaymentMode.PayUpFront
+ "PayUpFront" -> PaymentMode.PayUpFront
+ "unknown" -> PaymentMode.Unknown
+ "UNKNOWN" -> PaymentMode.Unknown
+ "Unknown" -> PaymentMode.Unknown
+ else -> throw IllegalArgumentException("Unknown PaymentMode value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
public enum class PaymentModeIOS(val rawValue: String) {
Empty("empty"),
FreeTrial("free-trial"),
@@ -723,6 +802,40 @@ public enum class SubscriptionPeriodIOS(val rawValue: String) {
fun toJson(): String = rawValue
}
+/**
+ * Subscription period unit for cross-platform use.
+ */
+public enum class SubscriptionPeriodUnit(val rawValue: String) {
+ Day("day"),
+ Week("week"),
+ Month("month"),
+ Year("year"),
+ Unknown("unknown")
+
+ companion object {
+ fun fromJson(value: String): SubscriptionPeriodUnit = when (value) {
+ "day" -> SubscriptionPeriodUnit.Day
+ "DAY" -> SubscriptionPeriodUnit.Day
+ "Day" -> SubscriptionPeriodUnit.Day
+ "week" -> SubscriptionPeriodUnit.Week
+ "WEEK" -> SubscriptionPeriodUnit.Week
+ "Week" -> SubscriptionPeriodUnit.Week
+ "month" -> SubscriptionPeriodUnit.Month
+ "MONTH" -> SubscriptionPeriodUnit.Month
+ "Month" -> SubscriptionPeriodUnit.Month
+ "year" -> SubscriptionPeriodUnit.Year
+ "YEAR" -> SubscriptionPeriodUnit.Year
+ "Year" -> SubscriptionPeriodUnit.Year
+ "unknown" -> SubscriptionPeriodUnit.Unknown
+ "UNKNOWN" -> SubscriptionPeriodUnit.Unknown
+ "Unknown" -> SubscriptionPeriodUnit.Unknown
+ else -> throw IllegalArgumentException("Unknown SubscriptionPeriodUnit value: $value")
+ }
+ }
+
+ fun toJson(): String = rawValue
+}
+
/**
* Replacement mode for subscription changes (Android)
* These modes determine how the subscription replacement affects billing.
@@ -1109,6 +1222,11 @@ public data class DiscountDisplayInfoAndroid(
)
}
+/**
+ * Discount information returned from the store.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class DiscountIOS(
val identifier: String,
val localizedPrice: String? = null,
@@ -1148,6 +1266,135 @@ public data class DiscountIOS(
)
}
+/**
+ * Standardized one-time product discount offer.
+ * Provides a unified interface for one-time purchase discounts across platforms.
+ *
+ * Currently supported on Android (Google Play Billing 7.0+).
+ * iOS does not support one-time purchase discounts in the same way.
+ *
+ * @see https://openiap.dev/docs/features/discount
+ */
+public data class DiscountOffer(
+ /**
+ * Currency code (ISO 4217, e.g., "USD")
+ */
+ val currency: String,
+ /**
+ * [Android] Fixed discount amount in micro-units.
+ * Only present for fixed amount discounts.
+ */
+ val discountAmountMicrosAndroid: String? = null,
+ /**
+ * Formatted display price string (e.g., "$4.99")
+ */
+ val displayPrice: String,
+ /**
+ * [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ */
+ val formattedDiscountAmountAndroid: String? = null,
+ /**
+ * [Android] Original full price in micro-units before discount.
+ * Divide by 1,000,000 to get the actual price.
+ * Use for displaying strikethrough original price.
+ */
+ val fullPriceMicrosAndroid: String? = null,
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Not applicable (one-time discounts not supported)
+ * - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ */
+ val id: String? = null,
+ /**
+ * [Android] Limited quantity information.
+ * Contains maximumQuantity and remainingQuantity.
+ */
+ val limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid? = null,
+ /**
+ * [Android] List of tags associated with this offer.
+ */
+ val offerTagsAndroid: List? = null,
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ val offerTokenAndroid: String? = null,
+ /**
+ * [Android] Percentage discount (e.g., 33 for 33% off).
+ * Only present for percentage-based discounts.
+ */
+ val percentageDiscountAndroid: Int? = null,
+ /**
+ * [Android] Pre-order details if this is a pre-order offer.
+ * Available in Google Play Billing Library 8.1.0+
+ */
+ val preorderDetailsAndroid: PreorderDetailsAndroid? = null,
+ /**
+ * Numeric price value
+ */
+ val price: Double,
+ /**
+ * [Android] Rental details if this is a rental offer.
+ */
+ val rentalDetailsAndroid: RentalDetailsAndroid? = null,
+ /**
+ * Type of discount offer
+ */
+ val type: DiscountOfferType,
+ /**
+ * [Android] Valid time window for the offer.
+ * Contains startTimeMillis and endTimeMillis.
+ */
+ val validTimeWindowAndroid: ValidTimeWindowAndroid? = null
+) {
+
+ companion object {
+ fun fromJson(json: Map): DiscountOffer {
+ return DiscountOffer(
+ currency = json["currency"] as? String ?: "",
+ discountAmountMicrosAndroid = json["discountAmountMicrosAndroid"] as? String,
+ displayPrice = json["displayPrice"] as? String ?: "",
+ formattedDiscountAmountAndroid = json["formattedDiscountAmountAndroid"] as? String,
+ fullPriceMicrosAndroid = json["fullPriceMicrosAndroid"] as? String,
+ id = json["id"] as? String,
+ limitedQuantityInfoAndroid = (json["limitedQuantityInfoAndroid"] as? Map)?.let { LimitedQuantityInfoAndroid.fromJson(it) },
+ offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String },
+ offerTokenAndroid = json["offerTokenAndroid"] as? String,
+ percentageDiscountAndroid = (json["percentageDiscountAndroid"] as? Number)?.toInt(),
+ preorderDetailsAndroid = (json["preorderDetailsAndroid"] as? Map)?.let { PreorderDetailsAndroid.fromJson(it) },
+ price = (json["price"] as? Number)?.toDouble() ?: 0.0,
+ rentalDetailsAndroid = (json["rentalDetailsAndroid"] as? Map)?.let { RentalDetailsAndroid.fromJson(it) },
+ type = (json["type"] as? String)?.let { DiscountOfferType.fromJson(it) } ?: DiscountOfferType.Introductory,
+ validTimeWindowAndroid = (json["validTimeWindowAndroid"] as? Map)?.let { ValidTimeWindowAndroid.fromJson(it) },
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "DiscountOffer",
+ "currency" to currency,
+ "discountAmountMicrosAndroid" to discountAmountMicrosAndroid,
+ "displayPrice" to displayPrice,
+ "formattedDiscountAmountAndroid" to formattedDiscountAmountAndroid,
+ "fullPriceMicrosAndroid" to fullPriceMicrosAndroid,
+ "id" to id,
+ "limitedQuantityInfoAndroid" to limitedQuantityInfoAndroid?.toJson(),
+ "offerTagsAndroid" to offerTagsAndroid?.map { it },
+ "offerTokenAndroid" to offerTokenAndroid,
+ "percentageDiscountAndroid" to percentageDiscountAndroid,
+ "preorderDetailsAndroid" to preorderDetailsAndroid?.toJson(),
+ "price" to price,
+ "rentalDetailsAndroid" to rentalDetailsAndroid?.toJson(),
+ "type" to type.toJson(),
+ "validTimeWindowAndroid" to validTimeWindowAndroid?.toJson(),
+ )
+}
+
+/**
+ * iOS DiscountOffer (output type).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class DiscountOfferIOS(
/**
* Discount identifier
@@ -1456,6 +1703,12 @@ public data class ProductAndroid(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ val discountOffers: List? = null,
override val displayName: String? = null,
override val displayPrice: String,
override val id: String,
@@ -1463,11 +1716,21 @@ public data class ProductAndroid(
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
*/
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionOfferDetailsAndroid: List? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
override val title: String,
override val type: ProductType = ProductType.InApp
) : ProductCommon, Product {
@@ -1478,6 +1741,7 @@ public data class ProductAndroid(
currency = json["currency"] as? String ?: "",
debugDescription = json["debugDescription"] as? String,
description = json["description"] as? String ?: "",
+ discountOffers = (json["discountOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DiscountOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DiscountOffer") },
displayName = json["displayName"] as? String,
displayPrice = json["displayPrice"] as? String ?: "",
id = json["id"] as? String ?: "",
@@ -1486,6 +1750,7 @@ public data class ProductAndroid(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
)
@@ -1497,6 +1762,7 @@ public data class ProductAndroid(
"currency" to currency,
"debugDescription" to debugDescription,
"description" to description,
+ "discountOffers" to discountOffers?.map { it.toJson() },
"displayName" to displayName,
"displayPrice" to displayPrice,
"id" to id,
@@ -1505,14 +1771,17 @@ public data class ProductAndroid(
"platform" to platform.toJson(),
"price" to price,
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid?.map { it.toJson() },
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
)
}
/**
- * One-time purchase offer details (Android)
+ * One-time purchase offer details (Android).
* Available in Google Play Billing Library 7.0+
+ * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#discount-offer
*/
public data class ProductAndroidOneTimePurchaseOfferDetail(
/**
@@ -1607,7 +1876,17 @@ public data class ProductIOS(
val jsonRepresentationIOS: String,
override val platform: IapPlatform = IapPlatform.Ios,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionInfoIOS: SubscriptionInfoIOS? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * Note: iOS does not support one-time product discounts.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
override val title: String,
override val type: ProductType = ProductType.InApp,
val typeIOS: ProductTypeIOS
@@ -1628,6 +1907,7 @@ public data class ProductIOS(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionInfoIOS = (json["subscriptionInfoIOS"] as? Map)?.let { SubscriptionInfoIOS.fromJson(it) },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
typeIOS = (json["typeIOS"] as? String)?.let { ProductTypeIOS.fromJson(it) } ?: ProductTypeIOS.Consumable,
@@ -1649,6 +1929,7 @@ public data class ProductIOS(
"platform" to platform.toJson(),
"price" to price,
"subscriptionInfoIOS" to subscriptionInfoIOS?.toJson(),
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
"typeIOS" to typeIOS.toJson(),
@@ -1659,6 +1940,12 @@ public data class ProductSubscriptionAndroid(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ val discountOffers: List? = null,
override val displayName: String? = null,
override val displayPrice: String,
override val id: String,
@@ -1666,11 +1953,21 @@ public data class ProductSubscriptionAndroid(
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
*/
val oneTimePurchaseOfferDetailsAndroid: List? = null,
override val platform: IapPlatform = IapPlatform.Android,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionOfferDetailsAndroid: List,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List,
override val title: String,
override val type: ProductType = ProductType.Subs
) : ProductCommon, ProductSubscription {
@@ -1681,6 +1978,7 @@ public data class ProductSubscriptionAndroid(
currency = json["currency"] as? String ?: "",
debugDescription = json["debugDescription"] as? String,
description = json["description"] as? String ?: "",
+ discountOffers = (json["discountOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DiscountOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DiscountOffer") },
displayName = json["displayName"] as? String,
displayPrice = json["displayPrice"] as? String ?: "",
id = json["id"] as? String ?: "",
@@ -1689,6 +1987,7 @@ public data class ProductSubscriptionAndroid(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(),
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(),
title = json["title"] as? String ?: "",
type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp,
)
@@ -1700,6 +1999,7 @@ public data class ProductSubscriptionAndroid(
"currency" to currency,
"debugDescription" to debugDescription,
"description" to description,
+ "discountOffers" to discountOffers?.map { it.toJson() },
"displayName" to displayName,
"displayPrice" to displayPrice,
"id" to id,
@@ -1708,11 +2008,17 @@ public data class ProductSubscriptionAndroid(
"platform" to platform.toJson(),
"price" to price,
"subscriptionOfferDetailsAndroid" to subscriptionOfferDetailsAndroid.map { it.toJson() },
+ "subscriptionOffers" to subscriptionOffers.map { it.toJson() },
"title" to title,
"type" to type.toJson(),
)
}
+/**
+ * Subscription offer details (Android).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class ProductSubscriptionAndroidOfferDetails(
val basePlanId: String,
val offerId: String? = null,
@@ -1747,6 +2053,9 @@ public data class ProductSubscriptionIOS(
override val currency: String,
override val debugDescription: String? = null,
override val description: String,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val discountsIOS: List? = null,
override val displayName: String? = null,
val displayNameIOS: String,
@@ -1761,7 +2070,16 @@ public data class ProductSubscriptionIOS(
val jsonRepresentationIOS: String,
override val platform: IapPlatform = IapPlatform.Ios,
override val price: Double? = null,
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ */
val subscriptionInfoIOS: SubscriptionInfoIOS? = null,
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ val subscriptionOffers: List? = null,
val subscriptionPeriodNumberIOS: String? = null,
val subscriptionPeriodUnitIOS: SubscriptionPeriodIOS? = null,
override val title: String,
@@ -1790,6 +2108,7 @@ public data class ProductSubscriptionIOS(
platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios,
price = (json["price"] as? Number)?.toDouble(),
subscriptionInfoIOS = (json["subscriptionInfoIOS"] as? Map)?.let { SubscriptionInfoIOS.fromJson(it) },
+ subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") },
subscriptionPeriodNumberIOS = json["subscriptionPeriodNumberIOS"] as? String,
subscriptionPeriodUnitIOS = (json["subscriptionPeriodUnitIOS"] as? String)?.let { SubscriptionPeriodIOS.fromJson(it) },
title = json["title"] as? String ?: "",
@@ -1819,6 +2138,7 @@ public data class ProductSubscriptionIOS(
"platform" to platform.toJson(),
"price" to price,
"subscriptionInfoIOS" to subscriptionInfoIOS?.toJson(),
+ "subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"subscriptionPeriodNumberIOS" to subscriptionPeriodNumberIOS,
"subscriptionPeriodUnitIOS" to subscriptionPeriodUnitIOS?.toJson(),
"title" to title,
@@ -2282,6 +2602,154 @@ public data class SubscriptionInfoIOS(
)
}
+/**
+ * Standardized subscription discount/promotional offer.
+ * Provides a unified interface for subscription offers across iOS and Android.
+ *
+ * Both platforms support subscription offers with different implementations:
+ * - iOS: Introductory offers, promotional offers with server-side signatures
+ * - Android: Offer tokens with pricing phases
+ *
+ * @see https://openiap.dev/docs/types/ios#discount-offer
+ * @see https://openiap.dev/docs/types/android#subscription-offer
+ */
+public data class SubscriptionOffer(
+ /**
+ * [Android] Base plan identifier.
+ * Identifies which base plan this offer belongs to.
+ */
+ val basePlanIdAndroid: String? = null,
+ /**
+ * Currency code (ISO 4217, e.g., "USD")
+ */
+ val currency: String? = null,
+ /**
+ * Formatted display price string (e.g., "$9.99/month")
+ */
+ val displayPrice: String,
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Discount identifier from App Store Connect
+ * - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ */
+ val id: String,
+ /**
+ * [iOS] Key identifier for signature validation.
+ * Used with server-side signature generation for promotional offers.
+ */
+ val keyIdentifierIOS: String? = null,
+ /**
+ * [iOS] Localized price string.
+ */
+ val localizedPriceIOS: String? = null,
+ /**
+ * [iOS] Cryptographic nonce (UUID) for signature validation.
+ * Must be generated server-side for each purchase attempt.
+ */
+ val nonceIOS: String? = null,
+ /**
+ * [iOS] Number of billing periods for this discount.
+ */
+ val numberOfPeriodsIOS: Int? = null,
+ /**
+ * [Android] List of tags associated with this offer.
+ */
+ val offerTagsAndroid: List? = null,
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ val offerTokenAndroid: String? = null,
+ /**
+ * Payment mode during the offer period
+ */
+ val paymentMode: PaymentMode? = null,
+ /**
+ * Subscription period for this offer
+ */
+ val period: SubscriptionPeriod? = null,
+ /**
+ * Number of periods the offer applies
+ */
+ val periodCount: Int? = null,
+ /**
+ * Numeric price value
+ */
+ val price: Double,
+ /**
+ * [Android] Pricing phases for this subscription offer.
+ * Contains detailed pricing information for each phase (trial, intro, regular).
+ */
+ val pricingPhasesAndroid: PricingPhasesAndroid? = null,
+ /**
+ * [iOS] Server-generated signature for promotional offer validation.
+ * Required when applying promotional offers on iOS.
+ */
+ val signatureIOS: String? = null,
+ /**
+ * [iOS] Timestamp when the signature was generated.
+ * Used for signature validation.
+ */
+ val timestampIOS: Double? = null,
+ /**
+ * Type of subscription offer (Introductory or Promotional)
+ */
+ val type: DiscountOfferType
+) {
+
+ companion object {
+ fun fromJson(json: Map): SubscriptionOffer {
+ return SubscriptionOffer(
+ basePlanIdAndroid = json["basePlanIdAndroid"] as? String,
+ currency = json["currency"] as? String,
+ displayPrice = json["displayPrice"] as? String ?: "",
+ id = json["id"] as? String ?: "",
+ keyIdentifierIOS = json["keyIdentifierIOS"] as? String,
+ localizedPriceIOS = json["localizedPriceIOS"] as? String,
+ nonceIOS = json["nonceIOS"] as? String,
+ numberOfPeriodsIOS = (json["numberOfPeriodsIOS"] as? Number)?.toInt(),
+ offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String },
+ offerTokenAndroid = json["offerTokenAndroid"] as? String,
+ paymentMode = (json["paymentMode"] as? String)?.let { PaymentMode.fromJson(it) },
+ period = (json["period"] as? Map)?.let { SubscriptionPeriod.fromJson(it) },
+ periodCount = (json["periodCount"] as? Number)?.toInt(),
+ price = (json["price"] as? Number)?.toDouble() ?: 0.0,
+ pricingPhasesAndroid = (json["pricingPhasesAndroid"] as? Map)?.let { PricingPhasesAndroid.fromJson(it) },
+ signatureIOS = json["signatureIOS"] as? String,
+ timestampIOS = (json["timestampIOS"] as? Number)?.toDouble(),
+ type = (json["type"] as? String)?.let { DiscountOfferType.fromJson(it) } ?: DiscountOfferType.Introductory,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "SubscriptionOffer",
+ "basePlanIdAndroid" to basePlanIdAndroid,
+ "currency" to currency,
+ "displayPrice" to displayPrice,
+ "id" to id,
+ "keyIdentifierIOS" to keyIdentifierIOS,
+ "localizedPriceIOS" to localizedPriceIOS,
+ "nonceIOS" to nonceIOS,
+ "numberOfPeriodsIOS" to numberOfPeriodsIOS,
+ "offerTagsAndroid" to offerTagsAndroid?.map { it },
+ "offerTokenAndroid" to offerTokenAndroid,
+ "paymentMode" to paymentMode?.toJson(),
+ "period" to period?.toJson(),
+ "periodCount" to periodCount,
+ "price" to price,
+ "pricingPhasesAndroid" to pricingPhasesAndroid?.toJson(),
+ "signatureIOS" to signatureIOS,
+ "timestampIOS" to timestampIOS,
+ "type" to type.toJson(),
+ )
+}
+
+/**
+ * iOS subscription offer details.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
public data class SubscriptionOfferIOS(
val displayPrice: String,
val id: String,
@@ -2318,6 +2786,36 @@ public data class SubscriptionOfferIOS(
)
}
+/**
+ * Subscription period value combining unit and count.
+ */
+public data class SubscriptionPeriod(
+ /**
+ * The period unit (day, week, month, year)
+ */
+ val unit: SubscriptionPeriodUnit,
+ /**
+ * The number of units (e.g., 1 for monthly, 3 for quarterly)
+ */
+ val value: Int
+) {
+
+ companion object {
+ fun fromJson(json: Map): SubscriptionPeriod {
+ return SubscriptionPeriod(
+ unit = (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } ?: SubscriptionPeriodUnit.Day,
+ value = (json["value"] as? Number)?.toInt() ?: 0,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "__typename" to "SubscriptionPeriod",
+ "unit" to unit.toJson(),
+ "value" to value,
+ )
+}
+
public data class SubscriptionPeriodValueIOS(
val unit: SubscriptionPeriodIOS,
val value: Int
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index 505e76a8..656e0b20 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -62,6 +62,17 @@ public enum DeveloperBillingLaunchModeAndroid: String, Codable, CaseIterable {
case callerWillLaunchLink = "caller-will-launch-link"
}
+/// Discount offer type enumeration.
+/// Categorizes the type of discount or promotional offer.
+public enum DiscountOfferType: String, Codable, CaseIterable {
+ /// Introductory offer for new subscribers (first-time purchase discount)
+ case introductory = "introductory"
+ /// Promotional offer for existing or returning subscribers
+ case promotional = "promotional"
+ /// One-time product discount (Android only, Google Play Billing 7.0+)
+ case oneTime = "one-time"
+}
+
public enum ErrorCode: String, Codable, CaseIterable {
case unknown = "unknown"
case userCancelled = "user-cancelled"
@@ -262,6 +273,19 @@ public enum IapStore: String, Codable, CaseIterable {
case horizon = "horizon"
}
+/// Payment mode for subscription offers.
+/// Determines how the user pays during the offer period.
+public enum PaymentMode: String, Codable, CaseIterable {
+ /// Free trial period - no charge during offer
+ case freeTrial = "free-trial"
+ /// Pay each period at reduced price
+ case payAsYouGo = "pay-as-you-go"
+ /// Pay full discounted amount upfront
+ case payUpFront = "pay-up-front"
+ /// Unknown or unspecified payment mode
+ case unknown = "unknown"
+}
+
public enum PaymentModeIOS: String, Codable, CaseIterable {
case empty = "empty"
case freeTrial = "free-trial"
@@ -310,6 +334,15 @@ public enum SubscriptionPeriodIOS: String, Codable, CaseIterable {
case empty = "empty"
}
+/// Subscription period unit for cross-platform use.
+public enum SubscriptionPeriodUnit: String, Codable, CaseIterable {
+ case day = "day"
+ case week = "week"
+ case month = "month"
+ case year = "year"
+ case unknown = "unknown"
+}
+
/// Replacement mode for subscription changes (Android)
/// These modes determine how the subscription replacement affects billing.
/// Available in Google Play Billing Library 8.1.0+
@@ -460,6 +493,9 @@ public struct DiscountDisplayInfoAndroid: Codable {
public var percentageDiscount: Int?
}
+/// Discount information returned from the store.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct DiscountIOS: Codable {
public var identifier: String
public var localizedPrice: String?
@@ -471,6 +507,59 @@ public struct DiscountIOS: Codable {
public var type: String
}
+/// Standardized one-time product discount offer.
+/// Provides a unified interface for one-time purchase discounts across platforms.
+///
+/// Currently supported on Android (Google Play Billing 7.0+).
+/// iOS does not support one-time purchase discounts in the same way.
+///
+/// @see https://openiap.dev/docs/features/discount
+public struct DiscountOffer: Codable {
+ /// Currency code (ISO 4217, e.g., "USD")
+ public var currency: String
+ /// [Android] Fixed discount amount in micro-units.
+ /// Only present for fixed amount discounts.
+ public var discountAmountMicrosAndroid: String?
+ /// Formatted display price string (e.g., "$4.99")
+ public var displayPrice: String
+ /// [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ public var formattedDiscountAmountAndroid: String?
+ /// [Android] Original full price in micro-units before discount.
+ /// Divide by 1,000,000 to get the actual price.
+ /// Use for displaying strikethrough original price.
+ public var fullPriceMicrosAndroid: String?
+ /// Unique identifier for the offer.
+ /// - iOS: Not applicable (one-time discounts not supported)
+ /// - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ public var id: String?
+ /// [Android] Limited quantity information.
+ /// Contains maximumQuantity and remainingQuantity.
+ public var limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid?
+ /// [Android] List of tags associated with this offer.
+ public var offerTagsAndroid: [String]?
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ public var offerTokenAndroid: String?
+ /// [Android] Percentage discount (e.g., 33 for 33% off).
+ /// Only present for percentage-based discounts.
+ public var percentageDiscountAndroid: Int?
+ /// [Android] Pre-order details if this is a pre-order offer.
+ /// Available in Google Play Billing Library 8.1.0+
+ public var preorderDetailsAndroid: PreorderDetailsAndroid?
+ /// Numeric price value
+ public var price: Double
+ /// [Android] Rental details if this is a rental offer.
+ public var rentalDetailsAndroid: RentalDetailsAndroid?
+ /// Type of discount offer
+ public var type: DiscountOfferType
+ /// [Android] Valid time window for the offer.
+ /// Contains startTimeMillis and endTimeMillis.
+ public var validTimeWindowAndroid: ValidTimeWindowAndroid?
+}
+
+/// iOS DiscountOffer (output type).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct DiscountOfferIOS: Codable {
/// Discount identifier
public var identifier: String
@@ -565,22 +654,34 @@ public struct ProductAndroid: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ public var discountOffers: [DiscountOffer]?
public var displayName: String?
public var displayPrice: String
public var id: String
public var nameAndroid: String
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]?
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var title: String
public var type: ProductType = .inApp
}
-/// One-time purchase offer details (Android)
+/// One-time purchase offer details (Android).
/// Available in Google Play Billing Library 7.0+
+/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#discount-offer
public struct ProductAndroidOneTimePurchaseOfferDetail: Codable {
/// Discount display information
/// Only available for discounted offers
@@ -620,7 +721,13 @@ public struct ProductIOS: Codable, ProductCommon {
public var jsonRepresentationIOS: String
public var platform: IapPlatform = .ios
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionInfoIOS: SubscriptionInfoIOS?
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// Note: iOS does not support one-time product discounts.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var title: String
public var type: ProductType = .inApp
public var typeIOS: ProductTypeIOS
@@ -630,20 +737,33 @@ public struct ProductSubscriptionAndroid: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ public var discountOffers: [DiscountOffer]?
public var displayName: String?
public var displayPrice: String
public var id: String
public var nameAndroid: String
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
public var oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail]?
public var platform: IapPlatform = .android
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails]
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]
public var title: String
public var type: ProductType = .subs
}
+/// Subscription offer details (Android).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct ProductSubscriptionAndroidOfferDetails: Codable {
public var basePlanId: String
public var offerId: String?
@@ -656,6 +776,7 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon {
public var currency: String
public var debugDescription: String?
public var description: String
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var discountsIOS: [DiscountIOS]?
public var displayName: String?
public var displayNameIOS: String
@@ -670,7 +791,12 @@ public struct ProductSubscriptionIOS: Codable, ProductCommon {
public var jsonRepresentationIOS: String
public var platform: IapPlatform = .ios
public var price: Double?
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
public var subscriptionInfoIOS: SubscriptionInfoIOS?
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ public var subscriptionOffers: [SubscriptionOffer]?
public var subscriptionPeriodNumberIOS: String?
public var subscriptionPeriodUnitIOS: SubscriptionPeriodIOS?
public var title: String
@@ -824,6 +950,66 @@ public struct SubscriptionInfoIOS: Codable {
public var subscriptionPeriod: SubscriptionPeriodValueIOS
}
+/// Standardized subscription discount/promotional offer.
+/// Provides a unified interface for subscription offers across iOS and Android.
+///
+/// Both platforms support subscription offers with different implementations:
+/// - iOS: Introductory offers, promotional offers with server-side signatures
+/// - Android: Offer tokens with pricing phases
+///
+/// @see https://openiap.dev/docs/types/ios#discount-offer
+/// @see https://openiap.dev/docs/types/android#subscription-offer
+public struct SubscriptionOffer: Codable {
+ /// [Android] Base plan identifier.
+ /// Identifies which base plan this offer belongs to.
+ public var basePlanIdAndroid: String?
+ /// Currency code (ISO 4217, e.g., "USD")
+ public var currency: String?
+ /// Formatted display price string (e.g., "$9.99/month")
+ public var displayPrice: String
+ /// Unique identifier for the offer.
+ /// - iOS: Discount identifier from App Store Connect
+ /// - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ public var id: String
+ /// [iOS] Key identifier for signature validation.
+ /// Used with server-side signature generation for promotional offers.
+ public var keyIdentifierIOS: String?
+ /// [iOS] Localized price string.
+ public var localizedPriceIOS: String?
+ /// [iOS] Cryptographic nonce (UUID) for signature validation.
+ /// Must be generated server-side for each purchase attempt.
+ public var nonceIOS: String?
+ /// [iOS] Number of billing periods for this discount.
+ public var numberOfPeriodsIOS: Int?
+ /// [Android] List of tags associated with this offer.
+ public var offerTagsAndroid: [String]?
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ public var offerTokenAndroid: String?
+ /// Payment mode during the offer period
+ public var paymentMode: PaymentMode?
+ /// Subscription period for this offer
+ public var period: SubscriptionPeriod?
+ /// Number of periods the offer applies
+ public var periodCount: Int?
+ /// Numeric price value
+ public var price: Double
+ /// [Android] Pricing phases for this subscription offer.
+ /// Contains detailed pricing information for each phase (trial, intro, regular).
+ public var pricingPhasesAndroid: PricingPhasesAndroid?
+ /// [iOS] Server-generated signature for promotional offer validation.
+ /// Required when applying promotional offers on iOS.
+ public var signatureIOS: String?
+ /// [iOS] Timestamp when the signature was generated.
+ /// Used for signature validation.
+ public var timestampIOS: Double?
+ /// Type of subscription offer (Introductory or Promotional)
+ public var type: DiscountOfferType
+}
+
+/// iOS subscription offer details.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
public struct SubscriptionOfferIOS: Codable {
public var displayPrice: String
public var id: String
@@ -834,6 +1020,14 @@ public struct SubscriptionOfferIOS: Codable {
public var type: SubscriptionOfferTypeIOS
}
+/// Subscription period value combining unit and count.
+public struct SubscriptionPeriod: Codable {
+ /// The period unit (day, week, month, year)
+ public var unit: SubscriptionPeriodUnit
+ /// The number of units (e.g., 1 for monthly, 3 for quarterly)
+ public var value: Int
+}
+
public struct SubscriptionPeriodValueIOS: Codable {
public var unit: SubscriptionPeriodIOS
public var value: Int
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index cdded08b..5591c89d 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -130,6 +130,40 @@ enum DeveloperBillingLaunchModeAndroid {
String toJson() => value;
}
+/// Discount offer type enumeration.
+/// Categorizes the type of discount or promotional offer.
+enum DiscountOfferType {
+ /// Introductory offer for new subscribers (first-time purchase discount)
+ Introductory('introductory'),
+ /// Promotional offer for existing or returning subscribers
+ Promotional('promotional'),
+ /// One-time product discount (Android only, Google Play Billing 7.0+)
+ OneTime('one-time');
+
+ const DiscountOfferType(this.value);
+ final String value;
+
+ factory DiscountOfferType.fromJson(String value) {
+ switch (value) {
+ case 'introductory':
+ case 'INTRODUCTORY':
+ case 'Introductory':
+ return DiscountOfferType.Introductory;
+ case 'promotional':
+ case 'PROMOTIONAL':
+ case 'Promotional':
+ return DiscountOfferType.Promotional;
+ case 'one-time':
+ case 'ONE_TIME':
+ case 'OneTime':
+ return DiscountOfferType.OneTime;
+ }
+ throw ArgumentError('Unknown DiscountOfferType value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum ErrorCode {
Unknown('unknown'),
UserCancelled('user-cancelled'),
@@ -578,6 +612,46 @@ enum IapStore {
String toJson() => value;
}
+/// Payment mode for subscription offers.
+/// Determines how the user pays during the offer period.
+enum PaymentMode {
+ /// Free trial period - no charge during offer
+ FreeTrial('free-trial'),
+ /// Pay each period at reduced price
+ PayAsYouGo('pay-as-you-go'),
+ /// Pay full discounted amount upfront
+ PayUpFront('pay-up-front'),
+ /// Unknown or unspecified payment mode
+ Unknown('unknown');
+
+ const PaymentMode(this.value);
+ final String value;
+
+ factory PaymentMode.fromJson(String value) {
+ switch (value) {
+ case 'free-trial':
+ case 'FREE_TRIAL':
+ case 'FreeTrial':
+ return PaymentMode.FreeTrial;
+ case 'pay-as-you-go':
+ case 'PAY_AS_YOU_GO':
+ case 'PayAsYouGo':
+ return PaymentMode.PayAsYouGo;
+ case 'pay-up-front':
+ case 'PAY_UP_FRONT':
+ case 'PayUpFront':
+ return PaymentMode.PayUpFront;
+ case 'unknown':
+ case 'UNKNOWN':
+ case 'Unknown':
+ return PaymentMode.Unknown;
+ }
+ throw ArgumentError('Unknown PaymentMode value: $value');
+ }
+
+ String toJson() => value;
+}
+
enum PaymentModeIOS {
Empty('empty'),
FreeTrial('free-trial'),
@@ -810,6 +884,46 @@ enum SubscriptionPeriodIOS {
String toJson() => value;
}
+/// Subscription period unit for cross-platform use.
+enum SubscriptionPeriodUnit {
+ Day('day'),
+ Week('week'),
+ Month('month'),
+ Year('year'),
+ Unknown('unknown');
+
+ const SubscriptionPeriodUnit(this.value);
+ final String value;
+
+ factory SubscriptionPeriodUnit.fromJson(String value) {
+ switch (value) {
+ case 'day':
+ case 'DAY':
+ case 'Day':
+ return SubscriptionPeriodUnit.Day;
+ case 'week':
+ case 'WEEK':
+ case 'Week':
+ return SubscriptionPeriodUnit.Week;
+ case 'month':
+ case 'MONTH':
+ case 'Month':
+ return SubscriptionPeriodUnit.Month;
+ case 'year':
+ case 'YEAR':
+ case 'Year':
+ return SubscriptionPeriodUnit.Year;
+ case 'unknown':
+ case 'UNKNOWN':
+ case 'Unknown':
+ return SubscriptionPeriodUnit.Unknown;
+ }
+ throw ArgumentError('Unknown SubscriptionPeriodUnit value: $value');
+ }
+
+ String toJson() => value;
+}
+
/// Replacement mode for subscription changes (Android)
/// These modes determine how the subscription replacement affects billing.
/// Available in Google Play Billing Library 8.1.0+
@@ -1198,6 +1312,9 @@ class DiscountDisplayInfoAndroid {
}
}
+/// Discount information returned from the store.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
class DiscountIOS {
const DiscountIOS({
required this.identifier,
@@ -1247,6 +1364,118 @@ class DiscountIOS {
}
}
+/// Standardized one-time product discount offer.
+/// Provides a unified interface for one-time purchase discounts across platforms.
+///
+/// Currently supported on Android (Google Play Billing 7.0+).
+/// iOS does not support one-time purchase discounts in the same way.
+///
+/// @see https://openiap.dev/docs/features/discount
+class DiscountOffer {
+ const DiscountOffer({
+ required this.currency,
+ this.discountAmountMicrosAndroid,
+ required this.displayPrice,
+ this.formattedDiscountAmountAndroid,
+ this.fullPriceMicrosAndroid,
+ this.id,
+ this.limitedQuantityInfoAndroid,
+ this.offerTagsAndroid,
+ this.offerTokenAndroid,
+ this.percentageDiscountAndroid,
+ this.preorderDetailsAndroid,
+ required this.price,
+ this.rentalDetailsAndroid,
+ required this.type,
+ this.validTimeWindowAndroid,
+ });
+
+ /// Currency code (ISO 4217, e.g., "USD")
+ final String currency;
+ /// [Android] Fixed discount amount in micro-units.
+ /// Only present for fixed amount discounts.
+ final String? discountAmountMicrosAndroid;
+ /// Formatted display price string (e.g., "$4.99")
+ final String displayPrice;
+ /// [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ final String? formattedDiscountAmountAndroid;
+ /// [Android] Original full price in micro-units before discount.
+ /// Divide by 1,000,000 to get the actual price.
+ /// Use for displaying strikethrough original price.
+ final String? fullPriceMicrosAndroid;
+ /// Unique identifier for the offer.
+ /// - iOS: Not applicable (one-time discounts not supported)
+ /// - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ final String? id;
+ /// [Android] Limited quantity information.
+ /// Contains maximumQuantity and remainingQuantity.
+ final LimitedQuantityInfoAndroid? limitedQuantityInfoAndroid;
+ /// [Android] List of tags associated with this offer.
+ final List? offerTagsAndroid;
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ final String? offerTokenAndroid;
+ /// [Android] Percentage discount (e.g., 33 for 33% off).
+ /// Only present for percentage-based discounts.
+ final int? percentageDiscountAndroid;
+ /// [Android] Pre-order details if this is a pre-order offer.
+ /// Available in Google Play Billing Library 8.1.0+
+ final PreorderDetailsAndroid? preorderDetailsAndroid;
+ /// Numeric price value
+ final double price;
+ /// [Android] Rental details if this is a rental offer.
+ final RentalDetailsAndroid? rentalDetailsAndroid;
+ /// Type of discount offer
+ final DiscountOfferType type;
+ /// [Android] Valid time window for the offer.
+ /// Contains startTimeMillis and endTimeMillis.
+ final ValidTimeWindowAndroid? validTimeWindowAndroid;
+
+ factory DiscountOffer.fromJson(Map json) {
+ return DiscountOffer(
+ currency: json['currency'] as String,
+ discountAmountMicrosAndroid: json['discountAmountMicrosAndroid'] as String?,
+ displayPrice: json['displayPrice'] as String,
+ formattedDiscountAmountAndroid: json['formattedDiscountAmountAndroid'] as String?,
+ fullPriceMicrosAndroid: json['fullPriceMicrosAndroid'] as String?,
+ id: json['id'] as String?,
+ limitedQuantityInfoAndroid: json['limitedQuantityInfoAndroid'] != null ? LimitedQuantityInfoAndroid.fromJson(json['limitedQuantityInfoAndroid'] as Map) : null,
+ offerTagsAndroid: (json['offerTagsAndroid'] as List?) == null ? null : (json['offerTagsAndroid'] as List?)!.map((e) => e as String).toList(),
+ offerTokenAndroid: json['offerTokenAndroid'] as String?,
+ percentageDiscountAndroid: json['percentageDiscountAndroid'] as int?,
+ preorderDetailsAndroid: json['preorderDetailsAndroid'] != null ? PreorderDetailsAndroid.fromJson(json['preorderDetailsAndroid'] as Map) : null,
+ price: (json['price'] as num).toDouble(),
+ rentalDetailsAndroid: json['rentalDetailsAndroid'] != null ? RentalDetailsAndroid.fromJson(json['rentalDetailsAndroid'] as Map) : null,
+ type: DiscountOfferType.fromJson(json['type'] as String),
+ validTimeWindowAndroid: json['validTimeWindowAndroid'] != null ? ValidTimeWindowAndroid.fromJson(json['validTimeWindowAndroid'] as Map) : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'DiscountOffer',
+ 'currency': currency,
+ 'discountAmountMicrosAndroid': discountAmountMicrosAndroid,
+ 'displayPrice': displayPrice,
+ 'formattedDiscountAmountAndroid': formattedDiscountAmountAndroid,
+ 'fullPriceMicrosAndroid': fullPriceMicrosAndroid,
+ 'id': id,
+ 'limitedQuantityInfoAndroid': limitedQuantityInfoAndroid?.toJson(),
+ 'offerTagsAndroid': offerTagsAndroid == null ? null : offerTagsAndroid!.map((e) => e).toList(),
+ 'offerTokenAndroid': offerTokenAndroid,
+ 'percentageDiscountAndroid': percentageDiscountAndroid,
+ 'preorderDetailsAndroid': preorderDetailsAndroid?.toJson(),
+ 'price': price,
+ 'rentalDetailsAndroid': rentalDetailsAndroid?.toJson(),
+ 'type': type.toJson(),
+ 'validTimeWindowAndroid': validTimeWindowAndroid?.toJson(),
+ };
+ }
+}
+
+/// iOS DiscountOffer (output type).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
class DiscountOfferIOS {
const DiscountOfferIOS({
required this.identifier,
@@ -1570,6 +1799,7 @@ class ProductAndroid extends Product implements ProductCommon {
required this.currency,
this.debugDescription,
required this.description,
+ this.discountOffers,
this.displayName,
required this.displayPrice,
required this.id,
@@ -1578,6 +1808,7 @@ class ProductAndroid extends Product implements ProductCommon {
this.platform = IapPlatform.Android,
this.price,
this.subscriptionOfferDetailsAndroid,
+ this.subscriptionOffers,
required this.title,
this.type = ProductType.InApp,
});
@@ -1585,16 +1816,26 @@ class ProductAndroid extends Product implements ProductCommon {
final String currency;
final String? debugDescription;
final String description;
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ final List? discountOffers;
final String? displayName;
final String displayPrice;
final String id;
final String nameAndroid;
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
final List? oneTimePurchaseOfferDetailsAndroid;
final IapPlatform platform;
final double? price;
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final List? subscriptionOfferDetailsAndroid;
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ final List? subscriptionOffers;
final String title;
final ProductType type;
@@ -1603,6 +1844,7 @@ class ProductAndroid extends Product implements ProductCommon {
currency: json['currency'] as String,
debugDescription: json['debugDescription'] as String?,
description: json['description'] as String,
+ discountOffers: (json['discountOffers'] as List?) == null ? null : (json['discountOffers'] as List?)!.map((e) => DiscountOffer.fromJson(e as Map)).toList(),
displayName: json['displayName'] as String?,
displayPrice: json['displayPrice'] as String,
id: json['id'] as String,
@@ -1611,6 +1853,7 @@ class ProductAndroid extends Product implements ProductCommon {
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List?) == null ? null : (json['subscriptionOfferDetailsAndroid'] as List?)!.map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(),
+ subscriptionOffers: (json['subscriptionOffers'] as List?) == null ? null : (json['subscriptionOffers'] as List?)!.map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
title: json['title'] as String,
type: ProductType.fromJson(json['type'] as String),
);
@@ -1623,6 +1866,7 @@ class ProductAndroid extends Product implements ProductCommon {
'currency': currency,
'debugDescription': debugDescription,
'description': description,
+ 'discountOffers': discountOffers == null ? null : discountOffers!.map((e) => e.toJson()).toList(),
'displayName': displayName,
'displayPrice': displayPrice,
'id': id,
@@ -1631,14 +1875,17 @@ class ProductAndroid extends Product implements ProductCommon {
'platform': platform.toJson(),
'price': price,
'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid == null ? null : subscriptionOfferDetailsAndroid!.map((e) => e.toJson()).toList(),
+ 'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(),
'title': title,
'type': type.toJson(),
};
}
}
-/// One-time purchase offer details (Android)
+/// One-time purchase offer details (Android).
/// Available in Google Play Billing Library 7.0+
+/// @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#discount-offer
class ProductAndroidOneTimePurchaseOfferDetail {
const ProductAndroidOneTimePurchaseOfferDetail({
this.discountDisplayInfo,
@@ -1730,6 +1977,7 @@ class ProductIOS extends Product implements ProductCommon {
this.platform = IapPlatform.IOS,
this.price,
this.subscriptionInfoIOS,
+ this.subscriptionOffers,
required this.title,
this.type = ProductType.InApp,
required this.typeIOS,
@@ -1746,7 +1994,13 @@ class ProductIOS extends Product implements ProductCommon {
final String jsonRepresentationIOS;
final IapPlatform platform;
final double? price;
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final SubscriptionInfoIOS? subscriptionInfoIOS;
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// Note: iOS does not support one-time product discounts.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ final List? subscriptionOffers;
final String title;
final ProductType type;
final ProductTypeIOS typeIOS;
@@ -1765,6 +2019,7 @@ class ProductIOS extends Product implements ProductCommon {
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
subscriptionInfoIOS: json['subscriptionInfoIOS'] != null ? SubscriptionInfoIOS.fromJson(json['subscriptionInfoIOS'] as Map) : null,
+ subscriptionOffers: (json['subscriptionOffers'] as List?) == null ? null : (json['subscriptionOffers'] as List?)!.map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
title: json['title'] as String,
type: ProductType.fromJson(json['type'] as String),
typeIOS: ProductTypeIOS.fromJson(json['typeIOS'] as String),
@@ -1787,6 +2042,7 @@ class ProductIOS extends Product implements ProductCommon {
'platform': platform.toJson(),
'price': price,
'subscriptionInfoIOS': subscriptionInfoIOS?.toJson(),
+ 'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(),
'title': title,
'type': type.toJson(),
'typeIOS': typeIOS.toJson(),
@@ -1799,6 +2055,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
required this.currency,
this.debugDescription,
required this.description,
+ this.discountOffers,
this.displayName,
required this.displayPrice,
required this.id,
@@ -1807,6 +2064,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
this.platform = IapPlatform.Android,
this.price,
required this.subscriptionOfferDetailsAndroid,
+ required this.subscriptionOffers,
required this.title,
this.type = ProductType.Subs,
});
@@ -1814,16 +2072,26 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
final String currency;
final String? debugDescription;
final String description;
+ /// Standardized discount offers for one-time products.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#discount-offer
+ final List? discountOffers;
final String? displayName;
final String displayPrice;
final String id;
final String nameAndroid;
/// One-time purchase offer details including discounts (Android)
/// Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ /// @deprecated Use discountOffers instead for cross-platform compatibility.
final List? oneTimePurchaseOfferDetailsAndroid;
final IapPlatform platform;
final double? price;
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final List subscriptionOfferDetailsAndroid;
+ /// Standardized subscription offers.
+ /// Cross-platform type with Android-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ final List subscriptionOffers;
final String title;
final ProductType type;
@@ -1832,6 +2100,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
currency: json['currency'] as String,
debugDescription: json['debugDescription'] as String?,
description: json['description'] as String,
+ discountOffers: (json['discountOffers'] as List?) == null ? null : (json['discountOffers'] as List?)!.map((e) => DiscountOffer.fromJson(e as Map)).toList(),
displayName: json['displayName'] as String?,
displayPrice: json['displayPrice'] as String,
id: json['id'] as String,
@@ -1840,6 +2109,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
subscriptionOfferDetailsAndroid: (json['subscriptionOfferDetailsAndroid'] as List).map((e) => ProductSubscriptionAndroidOfferDetails.fromJson(e as Map)).toList(),
+ subscriptionOffers: (json['subscriptionOffers'] as List).map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
title: json['title'] as String,
type: ProductType.fromJson(json['type'] as String),
);
@@ -1852,6 +2122,7 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
'currency': currency,
'debugDescription': debugDescription,
'description': description,
+ 'discountOffers': discountOffers == null ? null : discountOffers!.map((e) => e.toJson()).toList(),
'displayName': displayName,
'displayPrice': displayPrice,
'id': id,
@@ -1860,12 +2131,16 @@ class ProductSubscriptionAndroid extends ProductSubscription implements ProductC
'platform': platform.toJson(),
'price': price,
'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid.map((e) => e.toJson()).toList(),
+ 'subscriptionOffers': subscriptionOffers.map((e) => e.toJson()).toList(),
'title': title,
'type': type.toJson(),
};
}
}
+/// Subscription offer details (Android).
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
class ProductSubscriptionAndroidOfferDetails {
const ProductSubscriptionAndroidOfferDetails({
required this.basePlanId,
@@ -1923,6 +2198,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo
this.platform = IapPlatform.IOS,
this.price,
this.subscriptionInfoIOS,
+ this.subscriptionOffers,
this.subscriptionPeriodNumberIOS,
this.subscriptionPeriodUnitIOS,
required this.title,
@@ -1933,6 +2209,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo
final String currency;
final String? debugDescription;
final String description;
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final List? discountsIOS;
final String? displayName;
final String displayNameIOS;
@@ -1947,7 +2224,12 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo
final String jsonRepresentationIOS;
final IapPlatform platform;
final double? price;
+ /// @deprecated Use subscriptionOffers instead for cross-platform compatibility.
final SubscriptionInfoIOS? subscriptionInfoIOS;
+ /// Standardized subscription offers.
+ /// Cross-platform type with iOS-specific fields using suffix.
+ /// @see https://openiap.dev/docs/types#subscription-offer
+ final List? subscriptionOffers;
final String? subscriptionPeriodNumberIOS;
final SubscriptionPeriodIOS? subscriptionPeriodUnitIOS;
final String title;
@@ -1974,6 +2256,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo
platform: IapPlatform.fromJson(json['platform'] as String),
price: (json['price'] as num?)?.toDouble(),
subscriptionInfoIOS: json['subscriptionInfoIOS'] != null ? SubscriptionInfoIOS.fromJson(json['subscriptionInfoIOS'] as Map) : null,
+ subscriptionOffers: (json['subscriptionOffers'] as List?) == null ? null : (json['subscriptionOffers'] as List?)!.map((e) => SubscriptionOffer.fromJson(e as Map)).toList(),
subscriptionPeriodNumberIOS: json['subscriptionPeriodNumberIOS'] as String?,
subscriptionPeriodUnitIOS: json['subscriptionPeriodUnitIOS'] != null ? SubscriptionPeriodIOS.fromJson(json['subscriptionPeriodUnitIOS'] as String) : null,
title: json['title'] as String,
@@ -2004,6 +2287,7 @@ class ProductSubscriptionIOS extends ProductSubscription implements ProductCommo
'platform': platform.toJson(),
'price': price,
'subscriptionInfoIOS': subscriptionInfoIOS?.toJson(),
+ 'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(),
'subscriptionPeriodNumberIOS': subscriptionPeriodNumberIOS,
'subscriptionPeriodUnitIOS': subscriptionPeriodUnitIOS?.toJson(),
'title': title,
@@ -2553,6 +2837,134 @@ class SubscriptionInfoIOS {
}
}
+/// Standardized subscription discount/promotional offer.
+/// Provides a unified interface for subscription offers across iOS and Android.
+///
+/// Both platforms support subscription offers with different implementations:
+/// - iOS: Introductory offers, promotional offers with server-side signatures
+/// - Android: Offer tokens with pricing phases
+///
+/// @see https://openiap.dev/docs/types/ios#discount-offer
+/// @see https://openiap.dev/docs/types/android#subscription-offer
+class SubscriptionOffer {
+ const SubscriptionOffer({
+ this.basePlanIdAndroid,
+ this.currency,
+ required this.displayPrice,
+ required this.id,
+ this.keyIdentifierIOS,
+ this.localizedPriceIOS,
+ this.nonceIOS,
+ this.numberOfPeriodsIOS,
+ this.offerTagsAndroid,
+ this.offerTokenAndroid,
+ this.paymentMode,
+ this.period,
+ this.periodCount,
+ required this.price,
+ this.pricingPhasesAndroid,
+ this.signatureIOS,
+ this.timestampIOS,
+ required this.type,
+ });
+
+ /// [Android] Base plan identifier.
+ /// Identifies which base plan this offer belongs to.
+ final String? basePlanIdAndroid;
+ /// Currency code (ISO 4217, e.g., "USD")
+ final String? currency;
+ /// Formatted display price string (e.g., "$9.99/month")
+ final String displayPrice;
+ /// Unique identifier for the offer.
+ /// - iOS: Discount identifier from App Store Connect
+ /// - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ final String id;
+ /// [iOS] Key identifier for signature validation.
+ /// Used with server-side signature generation for promotional offers.
+ final String? keyIdentifierIOS;
+ /// [iOS] Localized price string.
+ final String? localizedPriceIOS;
+ /// [iOS] Cryptographic nonce (UUID) for signature validation.
+ /// Must be generated server-side for each purchase attempt.
+ final String? nonceIOS;
+ /// [iOS] Number of billing periods for this discount.
+ final int? numberOfPeriodsIOS;
+ /// [Android] List of tags associated with this offer.
+ final List? offerTagsAndroid;
+ /// [Android] Offer token required for purchase.
+ /// Must be passed to requestPurchase() when purchasing with this offer.
+ final String? offerTokenAndroid;
+ /// Payment mode during the offer period
+ final PaymentMode? paymentMode;
+ /// Subscription period for this offer
+ final SubscriptionPeriod? period;
+ /// Number of periods the offer applies
+ final int? periodCount;
+ /// Numeric price value
+ final double price;
+ /// [Android] Pricing phases for this subscription offer.
+ /// Contains detailed pricing information for each phase (trial, intro, regular).
+ final PricingPhasesAndroid? pricingPhasesAndroid;
+ /// [iOS] Server-generated signature for promotional offer validation.
+ /// Required when applying promotional offers on iOS.
+ final String? signatureIOS;
+ /// [iOS] Timestamp when the signature was generated.
+ /// Used for signature validation.
+ final double? timestampIOS;
+ /// Type of subscription offer (Introductory or Promotional)
+ final DiscountOfferType type;
+
+ factory SubscriptionOffer.fromJson(Map json) {
+ return SubscriptionOffer(
+ basePlanIdAndroid: json['basePlanIdAndroid'] as String?,
+ currency: json['currency'] as String?,
+ displayPrice: json['displayPrice'] as String,
+ id: json['id'] as String,
+ keyIdentifierIOS: json['keyIdentifierIOS'] as String?,
+ localizedPriceIOS: json['localizedPriceIOS'] as String?,
+ nonceIOS: json['nonceIOS'] as String?,
+ numberOfPeriodsIOS: json['numberOfPeriodsIOS'] as int?,
+ offerTagsAndroid: (json['offerTagsAndroid'] as List?) == null ? null : (json['offerTagsAndroid'] as List?)!.map((e) => e as String).toList(),
+ offerTokenAndroid: json['offerTokenAndroid'] as String?,
+ paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode'] as String) : null,
+ period: json['period'] != null ? SubscriptionPeriod.fromJson(json['period'] as Map) : null,
+ periodCount: json['periodCount'] as int?,
+ price: (json['price'] as num).toDouble(),
+ pricingPhasesAndroid: json['pricingPhasesAndroid'] != null ? PricingPhasesAndroid.fromJson(json['pricingPhasesAndroid'] as Map) : null,
+ signatureIOS: json['signatureIOS'] as String?,
+ timestampIOS: (json['timestampIOS'] as num?)?.toDouble(),
+ type: DiscountOfferType.fromJson(json['type'] as String),
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'SubscriptionOffer',
+ 'basePlanIdAndroid': basePlanIdAndroid,
+ 'currency': currency,
+ 'displayPrice': displayPrice,
+ 'id': id,
+ 'keyIdentifierIOS': keyIdentifierIOS,
+ 'localizedPriceIOS': localizedPriceIOS,
+ 'nonceIOS': nonceIOS,
+ 'numberOfPeriodsIOS': numberOfPeriodsIOS,
+ 'offerTagsAndroid': offerTagsAndroid == null ? null : offerTagsAndroid!.map((e) => e).toList(),
+ 'offerTokenAndroid': offerTokenAndroid,
+ 'paymentMode': paymentMode?.toJson(),
+ 'period': period?.toJson(),
+ 'periodCount': periodCount,
+ 'price': price,
+ 'pricingPhasesAndroid': pricingPhasesAndroid?.toJson(),
+ 'signatureIOS': signatureIOS,
+ 'timestampIOS': timestampIOS,
+ 'type': type.toJson(),
+ };
+ }
+}
+
+/// iOS subscription offer details.
+/// @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+/// @see https://openiap.dev/docs/types#subscription-offer
class SubscriptionOfferIOS {
const SubscriptionOfferIOS({
required this.displayPrice,
@@ -2598,6 +3010,34 @@ class SubscriptionOfferIOS {
}
}
+/// Subscription period value combining unit and count.
+class SubscriptionPeriod {
+ const SubscriptionPeriod({
+ required this.unit,
+ required this.value,
+ });
+
+ /// The period unit (day, week, month, year)
+ final SubscriptionPeriodUnit unit;
+ /// The number of units (e.g., 1 for monthly, 3 for quarterly)
+ final int value;
+
+ factory SubscriptionPeriod.fromJson(Map json) {
+ return SubscriptionPeriod(
+ unit: SubscriptionPeriodUnit.fromJson(json['unit'] as String),
+ value: json['value'] as int,
+ );
+ }
+
+ Map toJson() {
+ return {
+ '__typename': 'SubscriptionPeriod',
+ 'unit': unit.toJson(),
+ 'value': value,
+ };
+ }
+}
+
class SubscriptionPeriodValueIOS {
const SubscriptionPeriodValueIOS({
required this.unit,
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index 09ff4a7d..830bf179 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -45,6 +45,16 @@ enum DeveloperBillingLaunchModeAndroid {
CALLER_WILL_LAUNCH_LINK = 2,
}
+## Discount offer type enumeration. Categorizes the type of discount or promotional offer.
+enum DiscountOfferType {
+ ## Introductory offer for new subscribers (first-time purchase discount)
+ INTRODUCTORY = 0,
+ ## Promotional offer for existing or returning subscribers
+ PROMOTIONAL = 1,
+ ## One-time product discount (Android only, Google Play Billing 7.0+)
+ ONE_TIME = 2,
+}
+
enum ErrorCode {
UNKNOWN = 0,
USER_CANCELLED = 1,
@@ -156,6 +166,18 @@ enum IapStore {
HORIZON = 3,
}
+## Payment mode for subscription offers. Determines how the user pays during the offer period.
+enum PaymentMode {
+ ## Free trial period - no charge during offer
+ FREE_TRIAL = 0,
+ ## Pay each period at reduced price
+ PAY_AS_YOU_GO = 1,
+ ## Pay full discounted amount upfront
+ PAY_UP_FRONT = 2,
+ ## Unknown or unspecified payment mode
+ UNKNOWN = 3,
+}
+
enum PaymentModeIOS {
EMPTY = 0,
FREE_TRIAL = 1,
@@ -204,6 +226,15 @@ enum SubscriptionPeriodIOS {
EMPTY = 4,
}
+## Subscription period unit for cross-platform use.
+enum SubscriptionPeriodUnit {
+ DAY = 0,
+ WEEK = 1,
+ MONTH = 2,
+ YEAR = 3,
+ UNKNOWN = 4,
+}
+
## Replacement mode for subscription changes (Android) These modes determine how the subscription replacement affects billing. Available in Google Play Billing Library 8.1.0+
enum SubscriptionReplacementModeAndroid {
## Unknown replacement mode. Do not use.
@@ -476,6 +507,7 @@ class DiscountDisplayInfoAndroid:
dict["discountAmount"] = discount_amount
return dict
+## Discount information returned from the store. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer
class DiscountIOS:
var identifier: String
var type: String
@@ -521,6 +553,120 @@ class DiscountIOS:
dict["localizedPrice"] = localized_price
return dict
+## Standardized one-time product discount offer. Provides a unified interface for one-time purchase discounts across platforms. Currently supported on Android (Google Play Billing 7.0+). iOS does not support one-time purchase discounts in the same way. @see https://openiap.dev/docs/features/discount
+class DiscountOffer:
+ ## Unique identifier for the offer.
+ var id: String
+ ## Formatted display price string (e.g., "$4.99")
+ var display_price: String
+ ## Numeric price value
+ var price: float
+ ## Currency code (ISO 4217, e.g., "USD")
+ var currency: String
+ ## Type of discount offer
+ var type: DiscountOfferType
+ ## [Android] Offer token required for purchase.
+ var offer_token_android: String
+ ## [Android] List of tags associated with this offer.
+ var offer_tags_android: Array[String]
+ ## [Android] Original full price in micro-units before discount.
+ var full_price_micros_android: String
+ ## [Android] Percentage discount (e.g., 33 for 33% off).
+ var percentage_discount_android: int
+ ## [Android] Fixed discount amount in micro-units.
+ var discount_amount_micros_android: String
+ ## [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ var formatted_discount_amount_android: String
+ ## [Android] Valid time window for the offer.
+ var valid_time_window_android: ValidTimeWindowAndroid
+ ## [Android] Limited quantity information.
+ var limited_quantity_info_android: LimitedQuantityInfoAndroid
+ ## [Android] Pre-order details if this is a pre-order offer.
+ var preorder_details_android: PreorderDetailsAndroid
+ ## [Android] Rental details if this is a rental offer.
+ var rental_details_android: RentalDetailsAndroid
+
+ static func from_dict(data: Dictionary) -> DiscountOffer:
+ var obj = DiscountOffer.new()
+ if data.has("id") and data["id"] != null:
+ obj.id = data["id"]
+ if data.has("displayPrice") and data["displayPrice"] != null:
+ obj.display_price = data["displayPrice"]
+ if data.has("price") and data["price"] != null:
+ obj.price = data["price"]
+ if data.has("currency") and data["currency"] != null:
+ obj.currency = data["currency"]
+ if data.has("type") and data["type"] != null:
+ obj.type = data["type"]
+ if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null:
+ obj.offer_token_android = data["offerTokenAndroid"]
+ if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null:
+ obj.offer_tags_android = data["offerTagsAndroid"]
+ if data.has("fullPriceMicrosAndroid") and data["fullPriceMicrosAndroid"] != null:
+ obj.full_price_micros_android = data["fullPriceMicrosAndroid"]
+ if data.has("percentageDiscountAndroid") and data["percentageDiscountAndroid"] != null:
+ obj.percentage_discount_android = data["percentageDiscountAndroid"]
+ if data.has("discountAmountMicrosAndroid") and data["discountAmountMicrosAndroid"] != null:
+ obj.discount_amount_micros_android = data["discountAmountMicrosAndroid"]
+ if data.has("formattedDiscountAmountAndroid") and data["formattedDiscountAmountAndroid"] != null:
+ obj.formatted_discount_amount_android = data["formattedDiscountAmountAndroid"]
+ if data.has("validTimeWindowAndroid") and data["validTimeWindowAndroid"] != null:
+ if data["validTimeWindowAndroid"] is Dictionary:
+ obj.valid_time_window_android = ValidTimeWindowAndroid.from_dict(data["validTimeWindowAndroid"])
+ else:
+ obj.valid_time_window_android = data["validTimeWindowAndroid"]
+ if data.has("limitedQuantityInfoAndroid") and data["limitedQuantityInfoAndroid"] != null:
+ if data["limitedQuantityInfoAndroid"] is Dictionary:
+ obj.limited_quantity_info_android = LimitedQuantityInfoAndroid.from_dict(data["limitedQuantityInfoAndroid"])
+ else:
+ obj.limited_quantity_info_android = data["limitedQuantityInfoAndroid"]
+ if data.has("preorderDetailsAndroid") and data["preorderDetailsAndroid"] != null:
+ if data["preorderDetailsAndroid"] is Dictionary:
+ obj.preorder_details_android = PreorderDetailsAndroid.from_dict(data["preorderDetailsAndroid"])
+ else:
+ obj.preorder_details_android = data["preorderDetailsAndroid"]
+ if data.has("rentalDetailsAndroid") and data["rentalDetailsAndroid"] != null:
+ if data["rentalDetailsAndroid"] is Dictionary:
+ obj.rental_details_android = RentalDetailsAndroid.from_dict(data["rentalDetailsAndroid"])
+ else:
+ obj.rental_details_android = data["rentalDetailsAndroid"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ dict["id"] = id
+ dict["displayPrice"] = display_price
+ dict["price"] = price
+ dict["currency"] = currency
+ if DISCOUNT_OFFER_TYPE_VALUES.has(type):
+ dict["type"] = DISCOUNT_OFFER_TYPE_VALUES[type]
+ else:
+ dict["type"] = type
+ dict["offerTokenAndroid"] = offer_token_android
+ dict["offerTagsAndroid"] = offer_tags_android
+ dict["fullPriceMicrosAndroid"] = full_price_micros_android
+ dict["percentageDiscountAndroid"] = percentage_discount_android
+ dict["discountAmountMicrosAndroid"] = discount_amount_micros_android
+ dict["formattedDiscountAmountAndroid"] = formatted_discount_amount_android
+ if valid_time_window_android != null and valid_time_window_android.has_method("to_dict"):
+ dict["validTimeWindowAndroid"] = valid_time_window_android.to_dict()
+ else:
+ dict["validTimeWindowAndroid"] = valid_time_window_android
+ if limited_quantity_info_android != null and limited_quantity_info_android.has_method("to_dict"):
+ dict["limitedQuantityInfoAndroid"] = limited_quantity_info_android.to_dict()
+ else:
+ dict["limitedQuantityInfoAndroid"] = limited_quantity_info_android
+ if preorder_details_android != null and preorder_details_android.has_method("to_dict"):
+ dict["preorderDetailsAndroid"] = preorder_details_android.to_dict()
+ else:
+ dict["preorderDetailsAndroid"] = preorder_details_android
+ if rental_details_android != null and rental_details_android.has_method("to_dict"):
+ dict["rentalDetailsAndroid"] = rental_details_android.to_dict()
+ else:
+ dict["rentalDetailsAndroid"] = rental_details_android
+ return dict
+
+## iOS DiscountOffer (output type). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer
class DiscountOfferIOS:
## Discount identifier
var identifier: String
@@ -772,8 +918,13 @@ class ProductAndroid:
var debug_description: String
var platform: IapPlatform
var name_android: String
+ ## Standardized discount offers for one-time products.
+ var discount_offers: Array[DiscountOffer]
+ ## Standardized subscription offers.
+ var subscription_offers: Array[SubscriptionOffer]
## One-time purchase offer details including discounts (Android)
var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail]
+ ## @deprecated Use subscriptionOffers instead for cross-platform compatibility.
var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails]
static func from_dict(data: Dictionary) -> ProductAndroid:
@@ -800,6 +951,22 @@ class ProductAndroid:
obj.platform = data["platform"]
if data.has("nameAndroid") and data["nameAndroid"] != null:
obj.name_android = data["nameAndroid"]
+ if data.has("discountOffers") and data["discountOffers"] != null:
+ var arr = []
+ for item in data["discountOffers"]:
+ if item is Dictionary:
+ arr.append(DiscountOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.discount_offers = arr
+ if data.has("subscriptionOffers") and data["subscriptionOffers"] != null:
+ var arr = []
+ for item in data["subscriptionOffers"]:
+ if item is Dictionary:
+ arr.append(SubscriptionOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.subscription_offers = arr
if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null:
var arr = []
for item in data["oneTimePurchaseOfferDetailsAndroid"]:
@@ -837,6 +1004,26 @@ class ProductAndroid:
else:
dict["platform"] = platform
dict["nameAndroid"] = name_android
+ if discount_offers != null:
+ var arr = []
+ for item in discount_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["discountOffers"] = arr
+ else:
+ dict["discountOffers"] = null
+ if subscription_offers != null:
+ var arr = []
+ for item in subscription_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["subscriptionOffers"] = arr
+ else:
+ dict["subscriptionOffers"] = null
if one_time_purchase_offer_details_android != null:
var arr = []
for item in one_time_purchase_offer_details_android:
@@ -859,7 +1046,7 @@ class ProductAndroid:
dict["subscriptionOfferDetailsAndroid"] = null
return dict
-## One-time purchase offer details (Android) Available in Google Play Billing Library 7.0+
+## One-time purchase offer details (Android). Available in Google Play Billing Library 7.0+ @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#discount-offer
class ProductAndroidOneTimePurchaseOfferDetail:
## Offer ID
var offer_id: String
@@ -971,8 +1158,11 @@ class ProductIOS:
var display_name_ios: String
var is_family_shareable_ios: bool
var json_representation_ios: String
- var subscription_info_ios: SubscriptionInfoIOS
var type_ios: ProductTypeIOS
+ ## Standardized subscription offers.
+ var subscription_offers: Array[SubscriptionOffer]
+ ## @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ var subscription_info_ios: SubscriptionInfoIOS
static func from_dict(data: Dictionary) -> ProductIOS:
var obj = ProductIOS.new()
@@ -1002,13 +1192,21 @@ class ProductIOS:
obj.is_family_shareable_ios = data["isFamilyShareableIOS"]
if data.has("jsonRepresentationIOS") and data["jsonRepresentationIOS"] != null:
obj.json_representation_ios = data["jsonRepresentationIOS"]
+ if data.has("typeIOS") and data["typeIOS"] != null:
+ obj.type_ios = data["typeIOS"]
+ if data.has("subscriptionOffers") and data["subscriptionOffers"] != null:
+ var arr = []
+ for item in data["subscriptionOffers"]:
+ if item is Dictionary:
+ arr.append(SubscriptionOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.subscription_offers = arr
if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null:
if data["subscriptionInfoIOS"] is Dictionary:
obj.subscription_info_ios = SubscriptionInfoIOS.from_dict(data["subscriptionInfoIOS"])
else:
obj.subscription_info_ios = data["subscriptionInfoIOS"]
- if data.has("typeIOS") and data["typeIOS"] != null:
- obj.type_ios = data["typeIOS"]
return obj
func to_dict() -> Dictionary:
@@ -1032,14 +1230,24 @@ class ProductIOS:
dict["displayNameIOS"] = display_name_ios
dict["isFamilyShareableIOS"] = is_family_shareable_ios
dict["jsonRepresentationIOS"] = json_representation_ios
- if subscription_info_ios != null and subscription_info_ios.has_method("to_dict"):
- dict["subscriptionInfoIOS"] = subscription_info_ios.to_dict()
- else:
- dict["subscriptionInfoIOS"] = subscription_info_ios
if PRODUCT_TYPE_IOS_VALUES.has(type_ios):
dict["typeIOS"] = PRODUCT_TYPE_IOS_VALUES[type_ios]
else:
dict["typeIOS"] = type_ios
+ if subscription_offers != null:
+ var arr = []
+ for item in subscription_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["subscriptionOffers"] = arr
+ else:
+ dict["subscriptionOffers"] = null
+ if subscription_info_ios != null and subscription_info_ios.has_method("to_dict"):
+ dict["subscriptionInfoIOS"] = subscription_info_ios.to_dict()
+ else:
+ dict["subscriptionInfoIOS"] = subscription_info_ios
return dict
class ProductSubscriptionAndroid:
@@ -1054,8 +1262,13 @@ class ProductSubscriptionAndroid:
var debug_description: String
var platform: IapPlatform
var name_android: String
+ ## Standardized discount offers for one-time products.
+ var discount_offers: Array[DiscountOffer]
+ ## Standardized subscription offers.
+ var subscription_offers: Array[SubscriptionOffer]
## One-time purchase offer details including discounts (Android)
var one_time_purchase_offer_details_android: Array[ProductAndroidOneTimePurchaseOfferDetail]
+ ## @deprecated Use subscriptionOffers instead for cross-platform compatibility.
var subscription_offer_details_android: Array[ProductSubscriptionAndroidOfferDetails]
static func from_dict(data: Dictionary) -> ProductSubscriptionAndroid:
@@ -1082,6 +1295,22 @@ class ProductSubscriptionAndroid:
obj.platform = data["platform"]
if data.has("nameAndroid") and data["nameAndroid"] != null:
obj.name_android = data["nameAndroid"]
+ if data.has("discountOffers") and data["discountOffers"] != null:
+ var arr = []
+ for item in data["discountOffers"]:
+ if item is Dictionary:
+ arr.append(DiscountOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.discount_offers = arr
+ if data.has("subscriptionOffers") and data["subscriptionOffers"] != null:
+ var arr = []
+ for item in data["subscriptionOffers"]:
+ if item is Dictionary:
+ arr.append(SubscriptionOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.subscription_offers = arr
if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null:
var arr = []
for item in data["oneTimePurchaseOfferDetailsAndroid"]:
@@ -1119,6 +1348,26 @@ class ProductSubscriptionAndroid:
else:
dict["platform"] = platform
dict["nameAndroid"] = name_android
+ if discount_offers != null:
+ var arr = []
+ for item in discount_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["discountOffers"] = arr
+ else:
+ dict["discountOffers"] = null
+ if subscription_offers != null:
+ var arr = []
+ for item in subscription_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["subscriptionOffers"] = arr
+ else:
+ dict["subscriptionOffers"] = null
if one_time_purchase_offer_details_android != null:
var arr = []
for item in one_time_purchase_offer_details_android:
@@ -1141,6 +1390,7 @@ class ProductSubscriptionAndroid:
dict["subscriptionOfferDetailsAndroid"] = null
return dict
+## Subscription offer details (Android). @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer
class ProductSubscriptionAndroidOfferDetails:
var base_plan_id: String
var offer_id: String
@@ -1191,8 +1441,12 @@ class ProductSubscriptionIOS:
var display_name_ios: String
var is_family_shareable_ios: bool
var json_representation_ios: String
- var subscription_info_ios: SubscriptionInfoIOS
var type_ios: ProductTypeIOS
+ ## Standardized subscription offers.
+ var subscription_offers: Array[SubscriptionOffer]
+ ## @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ var subscription_info_ios: SubscriptionInfoIOS
+ ## @deprecated Use subscriptionOffers instead for cross-platform compatibility.
var discounts_ios: Array[DiscountIOS]
var introductory_price_ios: String
var introductory_price_as_amount_ios: String
@@ -1230,13 +1484,21 @@ class ProductSubscriptionIOS:
obj.is_family_shareable_ios = data["isFamilyShareableIOS"]
if data.has("jsonRepresentationIOS") and data["jsonRepresentationIOS"] != null:
obj.json_representation_ios = data["jsonRepresentationIOS"]
+ if data.has("typeIOS") and data["typeIOS"] != null:
+ obj.type_ios = data["typeIOS"]
+ if data.has("subscriptionOffers") and data["subscriptionOffers"] != null:
+ var arr = []
+ for item in data["subscriptionOffers"]:
+ if item is Dictionary:
+ arr.append(SubscriptionOffer.from_dict(item))
+ else:
+ arr.append(item)
+ obj.subscription_offers = arr
if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null:
if data["subscriptionInfoIOS"] is Dictionary:
obj.subscription_info_ios = SubscriptionInfoIOS.from_dict(data["subscriptionInfoIOS"])
else:
obj.subscription_info_ios = data["subscriptionInfoIOS"]
- if data.has("typeIOS") and data["typeIOS"] != null:
- obj.type_ios = data["typeIOS"]
if data.has("discountsIOS") and data["discountsIOS"] != null:
var arr = []
for item in data["discountsIOS"]:
@@ -1282,14 +1544,24 @@ class ProductSubscriptionIOS:
dict["displayNameIOS"] = display_name_ios
dict["isFamilyShareableIOS"] = is_family_shareable_ios
dict["jsonRepresentationIOS"] = json_representation_ios
- if subscription_info_ios != null and subscription_info_ios.has_method("to_dict"):
- dict["subscriptionInfoIOS"] = subscription_info_ios.to_dict()
- else:
- dict["subscriptionInfoIOS"] = subscription_info_ios
if PRODUCT_TYPE_IOS_VALUES.has(type_ios):
dict["typeIOS"] = PRODUCT_TYPE_IOS_VALUES[type_ios]
else:
dict["typeIOS"] = type_ios
+ if subscription_offers != null:
+ var arr = []
+ for item in subscription_offers:
+ if item != null and item.has_method("to_dict"):
+ arr.append(item.to_dict())
+ else:
+ arr.append(item)
+ dict["subscriptionOffers"] = arr
+ else:
+ dict["subscriptionOffers"] = null
+ if subscription_info_ios != null and subscription_info_ios.has_method("to_dict"):
+ dict["subscriptionInfoIOS"] = subscription_info_ios.to_dict()
+ else:
+ dict["subscriptionInfoIOS"] = subscription_info_ios
if discounts_ios != null:
var arr = []
for item in discounts_ios:
@@ -1823,6 +2095,126 @@ class SubscriptionInfoIOS:
dict["subscriptionPeriod"] = subscription_period
return dict
+## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/ios#discount-offer @see https://openiap.dev/docs/types/android#subscription-offer
+class SubscriptionOffer:
+ ## Unique identifier for the offer.
+ var id: String
+ ## Formatted display price string (e.g., "$9.99/month")
+ var display_price: String
+ ## Numeric price value
+ var price: float
+ ## Currency code (ISO 4217, e.g., "USD")
+ var currency: String
+ ## Type of subscription offer (Introductory or Promotional)
+ var type: DiscountOfferType
+ ## Subscription period for this offer
+ var period: SubscriptionPeriod
+ ## Number of periods the offer applies
+ var period_count: int
+ ## Payment mode during the offer period
+ var payment_mode: PaymentMode
+ ## [iOS] Key identifier for signature validation.
+ var key_identifier_ios: String
+ ## [iOS] Cryptographic nonce (UUID) for signature validation.
+ var nonce_ios: String
+ ## [iOS] Server-generated signature for promotional offer validation.
+ var signature_ios: String
+ ## [iOS] Timestamp when the signature was generated.
+ var timestamp_ios: float
+ ## [iOS] Number of billing periods for this discount.
+ var number_of_periods_ios: int
+ ## [iOS] Localized price string.
+ var localized_price_ios: String
+ ## [Android] Base plan identifier.
+ var base_plan_id_android: String
+ ## [Android] Offer token required for purchase.
+ var offer_token_android: String
+ ## [Android] List of tags associated with this offer.
+ var offer_tags_android: Array[String]
+ ## [Android] Pricing phases for this subscription offer.
+ var pricing_phases_android: PricingPhasesAndroid
+
+ static func from_dict(data: Dictionary) -> SubscriptionOffer:
+ var obj = SubscriptionOffer.new()
+ if data.has("id") and data["id"] != null:
+ obj.id = data["id"]
+ if data.has("displayPrice") and data["displayPrice"] != null:
+ obj.display_price = data["displayPrice"]
+ if data.has("price") and data["price"] != null:
+ obj.price = data["price"]
+ if data.has("currency") and data["currency"] != null:
+ obj.currency = data["currency"]
+ if data.has("type") and data["type"] != null:
+ obj.type = data["type"]
+ if data.has("period") and data["period"] != null:
+ if data["period"] is Dictionary:
+ obj.period = SubscriptionPeriod.from_dict(data["period"])
+ else:
+ obj.period = data["period"]
+ if data.has("periodCount") and data["periodCount"] != null:
+ obj.period_count = data["periodCount"]
+ if data.has("paymentMode") and data["paymentMode"] != null:
+ obj.payment_mode = data["paymentMode"]
+ if data.has("keyIdentifierIOS") and data["keyIdentifierIOS"] != null:
+ obj.key_identifier_ios = data["keyIdentifierIOS"]
+ if data.has("nonceIOS") and data["nonceIOS"] != null:
+ obj.nonce_ios = data["nonceIOS"]
+ if data.has("signatureIOS") and data["signatureIOS"] != null:
+ obj.signature_ios = data["signatureIOS"]
+ if data.has("timestampIOS") and data["timestampIOS"] != null:
+ obj.timestamp_ios = data["timestampIOS"]
+ if data.has("numberOfPeriodsIOS") and data["numberOfPeriodsIOS"] != null:
+ obj.number_of_periods_ios = data["numberOfPeriodsIOS"]
+ if data.has("localizedPriceIOS") and data["localizedPriceIOS"] != null:
+ obj.localized_price_ios = data["localizedPriceIOS"]
+ if data.has("basePlanIdAndroid") and data["basePlanIdAndroid"] != null:
+ obj.base_plan_id_android = data["basePlanIdAndroid"]
+ if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null:
+ obj.offer_token_android = data["offerTokenAndroid"]
+ if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null:
+ obj.offer_tags_android = data["offerTagsAndroid"]
+ if data.has("pricingPhasesAndroid") and data["pricingPhasesAndroid"] != null:
+ if data["pricingPhasesAndroid"] is Dictionary:
+ obj.pricing_phases_android = PricingPhasesAndroid.from_dict(data["pricingPhasesAndroid"])
+ else:
+ obj.pricing_phases_android = data["pricingPhasesAndroid"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ dict["id"] = id
+ dict["displayPrice"] = display_price
+ dict["price"] = price
+ dict["currency"] = currency
+ if DISCOUNT_OFFER_TYPE_VALUES.has(type):
+ dict["type"] = DISCOUNT_OFFER_TYPE_VALUES[type]
+ else:
+ dict["type"] = type
+ if period != null and period.has_method("to_dict"):
+ dict["period"] = period.to_dict()
+ else:
+ dict["period"] = period
+ dict["periodCount"] = period_count
+ if PAYMENT_MODE_VALUES.has(payment_mode):
+ dict["paymentMode"] = PAYMENT_MODE_VALUES[payment_mode]
+ else:
+ dict["paymentMode"] = payment_mode
+ dict["keyIdentifierIOS"] = key_identifier_ios
+ dict["nonceIOS"] = nonce_ios
+ dict["signatureIOS"] = signature_ios
+ dict["timestampIOS"] = timestamp_ios
+ dict["numberOfPeriodsIOS"] = number_of_periods_ios
+ dict["localizedPriceIOS"] = localized_price_ios
+ dict["basePlanIdAndroid"] = base_plan_id_android
+ dict["offerTokenAndroid"] = offer_token_android
+ dict["offerTagsAndroid"] = offer_tags_android
+ if pricing_phases_android != null and pricing_phases_android.has_method("to_dict"):
+ dict["pricingPhasesAndroid"] = pricing_phases_android.to_dict()
+ else:
+ dict["pricingPhasesAndroid"] = pricing_phases_android
+ return dict
+
+## iOS subscription offer details. @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility. @see https://openiap.dev/docs/types#subscription-offer
class SubscriptionOfferIOS:
var display_price: String
var id: String
@@ -1873,6 +2265,30 @@ class SubscriptionOfferIOS:
dict["type"] = type
return dict
+## Subscription period value combining unit and count.
+class SubscriptionPeriod:
+ ## The period unit (day, week, month, year)
+ var unit: SubscriptionPeriodUnit
+ ## The number of units (e.g., 1 for monthly, 3 for quarterly)
+ var value: int
+
+ static func from_dict(data: Dictionary) -> SubscriptionPeriod:
+ var obj = SubscriptionPeriod.new()
+ if data.has("unit") and data["unit"] != null:
+ obj.unit = data["unit"]
+ if data.has("value") and data["value"] != null:
+ obj.value = data["value"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ if SUBSCRIPTION_PERIOD_UNIT_VALUES.has(unit):
+ dict["unit"] = SUBSCRIPTION_PERIOD_UNIT_VALUES[unit]
+ else:
+ dict["unit"] = unit
+ dict["value"] = value
+ return dict
+
class SubscriptionPeriodValueIOS:
var unit: SubscriptionPeriodIOS
var value: int
@@ -3171,6 +3587,12 @@ const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES = {
DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK: "caller-will-launch-link"
}
+const DISCOUNT_OFFER_TYPE_VALUES = {
+ DiscountOfferType.INTRODUCTORY: "introductory",
+ DiscountOfferType.PROMOTIONAL: "promotional",
+ DiscountOfferType.ONE_TIME: "one-time"
+}
+
const ERROR_CODE_VALUES = {
ErrorCode.UNKNOWN: "unknown",
ErrorCode.USER_CANCELLED: "user-cancelled",
@@ -3260,6 +3682,13 @@ const IAP_STORE_VALUES = {
IapStore.HORIZON: "horizon"
}
+const PAYMENT_MODE_VALUES = {
+ PaymentMode.FREE_TRIAL: "free-trial",
+ PaymentMode.PAY_AS_YOU_GO: "pay-as-you-go",
+ PaymentMode.PAY_UP_FRONT: "pay-up-front",
+ PaymentMode.UNKNOWN: "unknown"
+}
+
const PAYMENT_MODE_IOS_VALUES = {
PaymentModeIOS.EMPTY: "empty",
PaymentModeIOS.FREE_TRIAL: "free-trial",
@@ -3308,6 +3737,14 @@ const SUBSCRIPTION_PERIOD_IOS_VALUES = {
SubscriptionPeriodIOS.EMPTY: "empty"
}
+const SUBSCRIPTION_PERIOD_UNIT_VALUES = {
+ SubscriptionPeriodUnit.DAY: "day",
+ SubscriptionPeriodUnit.WEEK: "week",
+ SubscriptionPeriodUnit.MONTH: "month",
+ SubscriptionPeriodUnit.YEAR: "year",
+ SubscriptionPeriodUnit.UNKNOWN: "unknown"
+}
+
const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = {
SubscriptionReplacementModeAndroid.UNKNOWN_REPLACEMENT_MODE: "unknown-replacement-mode",
SubscriptionReplacementModeAndroid.WITH_TIME_PRORATION: "with-time-proration",
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index e316c86e..5c9f8b82 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -169,6 +169,11 @@ export interface DiscountDisplayInfoAndroid {
percentageDiscount?: (number | null);
}
+/**
+ * Discount information returned from the store.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
export interface DiscountIOS {
identifier: string;
localizedPrice?: (string | null);
@@ -180,6 +185,79 @@ export interface DiscountIOS {
type: string;
}
+/**
+ * Standardized one-time product discount offer.
+ * Provides a unified interface for one-time purchase discounts across platforms.
+ *
+ * Currently supported on Android (Google Play Billing 7.0+).
+ * iOS does not support one-time purchase discounts in the same way.
+ *
+ * @see https://openiap.dev/docs/features/discount
+ */
+export interface DiscountOffer {
+ /** Currency code (ISO 4217, e.g., "USD") */
+ currency: string;
+ /**
+ * [Android] Fixed discount amount in micro-units.
+ * Only present for fixed amount discounts.
+ */
+ discountAmountMicrosAndroid?: (string | null);
+ /** Formatted display price string (e.g., "$4.99") */
+ displayPrice: string;
+ /** [Android] Formatted discount amount string (e.g., "$5.00 OFF"). */
+ formattedDiscountAmountAndroid?: (string | null);
+ /**
+ * [Android] Original full price in micro-units before discount.
+ * Divide by 1,000,000 to get the actual price.
+ * Use for displaying strikethrough original price.
+ */
+ fullPriceMicrosAndroid?: (string | null);
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Not applicable (one-time discounts not supported)
+ * - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ */
+ id?: (string | null);
+ /**
+ * [Android] Limited quantity information.
+ * Contains maximumQuantity and remainingQuantity.
+ */
+ limitedQuantityInfoAndroid?: (LimitedQuantityInfoAndroid | null);
+ /** [Android] List of tags associated with this offer. */
+ offerTagsAndroid?: (string[] | null);
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ offerTokenAndroid?: (string | null);
+ /**
+ * [Android] Percentage discount (e.g., 33 for 33% off).
+ * Only present for percentage-based discounts.
+ */
+ percentageDiscountAndroid?: (number | null);
+ /**
+ * [Android] Pre-order details if this is a pre-order offer.
+ * Available in Google Play Billing Library 8.1.0+
+ */
+ preorderDetailsAndroid?: (PreorderDetailsAndroid | null);
+ /** Numeric price value */
+ price: number;
+ /** [Android] Rental details if this is a rental offer. */
+ rentalDetailsAndroid?: (RentalDetailsAndroid | null);
+ /** Type of discount offer */
+ type: DiscountOfferType;
+ /**
+ * [Android] Valid time window for the offer.
+ * Contains startTimeMillis and endTimeMillis.
+ */
+ validTimeWindowAndroid?: (ValidTimeWindowAndroid | null);
+}
+
+/**
+ * iOS DiscountOffer (output type).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
export interface DiscountOfferIOS {
/** Discount identifier */
identifier: string;
@@ -206,6 +284,12 @@ export interface DiscountOfferInputIOS {
timestamp: number;
}
+/**
+ * Discount offer type enumeration.
+ * Categorizes the type of discount or promotional offer.
+ */
+export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time';
+
export interface EntitlementIOS {
jsonRepresentation: string;
sku: string;
@@ -517,6 +601,12 @@ export type MutationVerifyPurchaseArgs = VerifyPurchaseProps;
export type MutationVerifyPurchaseWithProviderArgs = VerifyPurchaseWithProviderProps;
+/**
+ * Payment mode for subscription offers.
+ * Determines how the user pays during the offer period.
+ */
+export type PaymentMode = 'free-trial' | 'pay-as-you-go' | 'pay-up-front' | 'unknown';
+
export type PaymentModeIOS = 'empty' | 'free-trial' | 'pay-as-you-go' | 'pay-up-front';
/**
@@ -555,6 +645,12 @@ export interface ProductAndroid extends ProductCommon {
currency: string;
debugDescription?: (string | null);
description: string;
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ discountOffers?: (DiscountOffer[] | null);
displayName?: (string | null);
displayPrice: string;
id: string;
@@ -562,18 +658,32 @@ export interface ProductAndroid extends ProductCommon {
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
+ * @deprecated Use discountOffers instead
*/
oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null);
platform: 'android';
price?: (number | null);
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ * @deprecated Use subscriptionOffers instead
+ */
subscriptionOfferDetailsAndroid?: (ProductSubscriptionAndroidOfferDetails[] | null);
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ subscriptionOffers?: (SubscriptionOffer[] | null);
title: string;
type: 'in-app';
}
/**
- * One-time purchase offer details (Android)
+ * One-time purchase offer details (Android).
* Available in Google Play Billing Library 7.0+
+ * @deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#discount-offer
*/
export interface ProductAndroidOneTimePurchaseOfferDetail {
/**
@@ -633,7 +743,18 @@ export interface ProductIOS extends ProductCommon {
jsonRepresentationIOS: string;
platform: 'ios';
price?: (number | null);
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ * @deprecated Use subscriptionOffers instead
+ */
subscriptionInfoIOS?: (SubscriptionInfoIOS | null);
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * Note: iOS does not support one-time product discounts.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ subscriptionOffers?: (SubscriptionOffer[] | null);
title: string;
type: 'in-app';
typeIOS: ProductTypeIOS;
@@ -654,6 +775,12 @@ export interface ProductSubscriptionAndroid extends ProductCommon {
currency: string;
debugDescription?: (string | null);
description: string;
+ /**
+ * Standardized discount offers for one-time products.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#discount-offer
+ */
+ discountOffers?: (DiscountOffer[] | null);
displayName?: (string | null);
displayPrice: string;
id: string;
@@ -661,15 +788,32 @@ export interface ProductSubscriptionAndroid extends ProductCommon {
/**
* One-time purchase offer details including discounts (Android)
* Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ * @deprecated Use discountOffers instead for cross-platform compatibility.
+ * @deprecated Use discountOffers instead
*/
oneTimePurchaseOfferDetailsAndroid?: (ProductAndroidOneTimePurchaseOfferDetail[] | null);
platform: 'android';
price?: (number | null);
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ * @deprecated Use subscriptionOffers instead
+ */
subscriptionOfferDetailsAndroid: ProductSubscriptionAndroidOfferDetails[];
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with Android-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ subscriptionOffers: SubscriptionOffer[];
title: string;
type: 'subs';
}
+/**
+ * Subscription offer details (Android).
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
export interface ProductSubscriptionAndroidOfferDetails {
basePlanId: string;
offerId?: (string | null);
@@ -682,6 +826,10 @@ export interface ProductSubscriptionIOS extends ProductCommon {
currency: string;
debugDescription?: (string | null);
description: string;
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ * @deprecated Use subscriptionOffers instead
+ */
discountsIOS?: (DiscountIOS[] | null);
displayName?: (string | null);
displayNameIOS: string;
@@ -696,7 +844,17 @@ export interface ProductSubscriptionIOS extends ProductCommon {
jsonRepresentationIOS: string;
platform: 'ios';
price?: (number | null);
+ /**
+ * @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ * @deprecated Use subscriptionOffers instead
+ */
subscriptionInfoIOS?: (SubscriptionInfoIOS | null);
+ /**
+ * Standardized subscription offers.
+ * Cross-platform type with iOS-specific fields using suffix.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
+ subscriptionOffers?: (SubscriptionOffer[] | null);
subscriptionPeriodNumberIOS?: (string | null);
subscriptionPeriodUnitIOS?: (SubscriptionPeriodIOS | null);
title: string;
@@ -1167,6 +1325,86 @@ export interface SubscriptionInfoIOS {
subscriptionPeriod: SubscriptionPeriodValueIOS;
}
+/**
+ * Standardized subscription discount/promotional offer.
+ * Provides a unified interface for subscription offers across iOS and Android.
+ *
+ * Both platforms support subscription offers with different implementations:
+ * - iOS: Introductory offers, promotional offers with server-side signatures
+ * - Android: Offer tokens with pricing phases
+ *
+ * @see https://openiap.dev/docs/types/ios#discount-offer
+ * @see https://openiap.dev/docs/types/android#subscription-offer
+ */
+export interface SubscriptionOffer {
+ /**
+ * [Android] Base plan identifier.
+ * Identifies which base plan this offer belongs to.
+ */
+ basePlanIdAndroid?: (string | null);
+ /** Currency code (ISO 4217, e.g., "USD") */
+ currency?: (string | null);
+ /** Formatted display price string (e.g., "$9.99/month") */
+ displayPrice: string;
+ /**
+ * Unique identifier for the offer.
+ * - iOS: Discount identifier from App Store Connect
+ * - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ */
+ id: string;
+ /**
+ * [iOS] Key identifier for signature validation.
+ * Used with server-side signature generation for promotional offers.
+ */
+ keyIdentifierIOS?: (string | null);
+ /** [iOS] Localized price string. */
+ localizedPriceIOS?: (string | null);
+ /**
+ * [iOS] Cryptographic nonce (UUID) for signature validation.
+ * Must be generated server-side for each purchase attempt.
+ */
+ nonceIOS?: (string | null);
+ /** [iOS] Number of billing periods for this discount. */
+ numberOfPeriodsIOS?: (number | null);
+ /** [Android] List of tags associated with this offer. */
+ offerTagsAndroid?: (string[] | null);
+ /**
+ * [Android] Offer token required for purchase.
+ * Must be passed to requestPurchase() when purchasing with this offer.
+ */
+ offerTokenAndroid?: (string | null);
+ /** Payment mode during the offer period */
+ paymentMode?: (PaymentMode | null);
+ /** Subscription period for this offer */
+ period?: (SubscriptionPeriod | null);
+ /** Number of periods the offer applies */
+ periodCount?: (number | null);
+ /** Numeric price value */
+ price: number;
+ /**
+ * [Android] Pricing phases for this subscription offer.
+ * Contains detailed pricing information for each phase (trial, intro, regular).
+ */
+ pricingPhasesAndroid?: (PricingPhasesAndroid | null);
+ /**
+ * [iOS] Server-generated signature for promotional offer validation.
+ * Required when applying promotional offers on iOS.
+ */
+ signatureIOS?: (string | null);
+ /**
+ * [iOS] Timestamp when the signature was generated.
+ * Used for signature validation.
+ */
+ timestampIOS?: (number | null);
+ /** Type of subscription offer (Introductory or Promotional) */
+ type: DiscountOfferType;
+}
+
+/**
+ * iOS subscription offer details.
+ * @deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+ * @see https://openiap.dev/docs/types#subscription-offer
+ */
export interface SubscriptionOfferIOS {
displayPrice: string;
id: string;
@@ -1179,8 +1417,19 @@ export interface SubscriptionOfferIOS {
export type SubscriptionOfferTypeIOS = 'introductory' | 'promotional';
+/** Subscription period value combining unit and count. */
+export interface SubscriptionPeriod {
+ /** The period unit (day, week, month, year) */
+ unit: SubscriptionPeriodUnit;
+ /** The number of units (e.g., 1 for monthly, 3 for quarterly) */
+ value: number;
+}
+
export type SubscriptionPeriodIOS = 'day' | 'week' | 'month' | 'year' | 'empty';
+/** Subscription period unit for cross-platform use. */
+export type SubscriptionPeriodUnit = 'day' | 'week' | 'month' | 'year' | 'unknown';
+
export interface SubscriptionPeriodValueIOS {
unit: SubscriptionPeriodIOS;
value: number;
diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql
index 625f8876..2c197edc 100644
--- a/packages/gql/src/type-android.graphql
+++ b/packages/gql/src/type-android.graphql
@@ -110,10 +110,13 @@ type DiscountDisplayInfoAndroid {
}
"""
-One-time purchase offer details (Android)
+One-time purchase offer details (Android).
Available in Google Play Billing Library 7.0+
+@deprecated Use the standardized DiscountOffer type instead for cross-platform compatibility.
+@see https://openiap.dev/docs/types#discount-offer
"""
-type ProductAndroidOneTimePurchaseOfferDetail {
+type ProductAndroidOneTimePurchaseOfferDetail
+ @deprecated(reason: "Use DiscountOffer type instead") {
"""
Offer ID
"""
@@ -158,7 +161,13 @@ type ProductAndroidOneTimePurchaseOfferDetail {
rentalDetailsAndroid: RentalDetailsAndroid
}
-type ProductSubscriptionAndroidOfferDetails {
+"""
+Subscription offer details (Android).
+@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+@see https://openiap.dev/docs/types#subscription-offer
+"""
+type ProductSubscriptionAndroidOfferDetails
+ @deprecated(reason: "Use SubscriptionOffer type instead") {
basePlanId: String!
offerId: String
offerToken: String!
@@ -181,12 +190,35 @@ type ProductAndroid implements ProductCommon {
# Android-specific
nameAndroid: String!
+
+ # Standardized cross-platform fields
+ """
+ Standardized discount offers for one-time products.
+ Cross-platform type with Android-specific fields using suffix.
+ @see https://openiap.dev/docs/types#discount-offer
+ """
+ discountOffers: [DiscountOffer!]
+
+ """
+ Standardized subscription offers.
+ Cross-platform type with Android-specific fields using suffix.
+ @see https://openiap.dev/docs/types#subscription-offer
+ """
+ subscriptionOffers: [SubscriptionOffer!]
+
+ # Deprecated platform-specific fields
"""
One-time purchase offer details including discounts (Android)
Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ @deprecated Use discountOffers instead for cross-platform compatibility.
"""
oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!]
+ @deprecated(reason: "Use discountOffers instead")
+ """
+ @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ """
subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!]
+ @deprecated(reason: "Use subscriptionOffers instead")
}
type ProductSubscriptionAndroid implements ProductCommon {
@@ -204,12 +236,35 @@ type ProductSubscriptionAndroid implements ProductCommon {
# Android-specific
nameAndroid: String!
+
+ # Standardized cross-platform fields
+ """
+ Standardized discount offers for one-time products.
+ Cross-platform type with Android-specific fields using suffix.
+ @see https://openiap.dev/docs/types#discount-offer
+ """
+ discountOffers: [DiscountOffer!]
+
+ """
+ Standardized subscription offers.
+ Cross-platform type with Android-specific fields using suffix.
+ @see https://openiap.dev/docs/types#subscription-offer
+ """
+ subscriptionOffers: [SubscriptionOffer!]!
+
+ # Deprecated platform-specific fields
"""
One-time purchase offer details including discounts (Android)
Returns all eligible offers. Available in Google Play Billing Library 7.0+
+ @deprecated Use discountOffers instead for cross-platform compatibility.
"""
oneTimePurchaseOfferDetailsAndroid: [ProductAndroidOneTimePurchaseOfferDetail!]
+ @deprecated(reason: "Use discountOffers instead")
+ """
+ @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ """
subscriptionOfferDetailsAndroid: [ProductSubscriptionAndroidOfferDetails!]!
+ @deprecated(reason: "Use subscriptionOffers instead")
}
type PurchaseAndroid implements PurchaseCommon {
diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql
index d507f637..3c2c445c 100644
--- a/packages/gql/src/type-ios.graphql
+++ b/packages/gql/src/type-ios.graphql
@@ -38,7 +38,13 @@ type SubscriptionPeriodValueIOS {
value: Int!
}
-type SubscriptionOfferIOS {
+"""
+iOS subscription offer details.
+@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+@see https://openiap.dev/docs/types#subscription-offer
+"""
+type SubscriptionOfferIOS
+ @deprecated(reason: "Use SubscriptionOffer type instead") {
displayPrice: String!
id: ID!
paymentMode: PaymentModeIOS!
@@ -73,8 +79,23 @@ type ProductIOS implements ProductCommon {
displayNameIOS: String!
isFamilyShareableIOS: Boolean!
jsonRepresentationIOS: String!
- subscriptionInfoIOS: SubscriptionInfoIOS
typeIOS: ProductTypeIOS!
+
+ # Standardized cross-platform fields
+ """
+ Standardized subscription offers.
+ Cross-platform type with iOS-specific fields using suffix.
+ Note: iOS does not support one-time product discounts.
+ @see https://openiap.dev/docs/types#subscription-offer
+ """
+ subscriptionOffers: [SubscriptionOffer!]
+
+ # Deprecated platform-specific field
+ """
+ @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ """
+ subscriptionInfoIOS: SubscriptionInfoIOS
+ @deprecated(reason: "Use subscriptionOffers instead")
}
# iOS subscription product
@@ -95,10 +116,28 @@ type ProductSubscriptionIOS implements ProductCommon {
displayNameIOS: String!
isFamilyShareableIOS: Boolean!
jsonRepresentationIOS: String!
- subscriptionInfoIOS: SubscriptionInfoIOS
typeIOS: ProductTypeIOS!
- discountsIOS: [DiscountIOS!]
+ # Standardized cross-platform fields
+ """
+ Standardized subscription offers.
+ Cross-platform type with iOS-specific fields using suffix.
+ @see https://openiap.dev/docs/types#subscription-offer
+ """
+ subscriptionOffers: [SubscriptionOffer!]
+
+ # Deprecated platform-specific fields
+ """
+ @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ """
+ subscriptionInfoIOS: SubscriptionInfoIOS
+ @deprecated(reason: "Use subscriptionOffers instead")
+
+ """
+ @deprecated Use subscriptionOffers instead for cross-platform compatibility.
+ """
+ discountsIOS: [DiscountIOS!] @deprecated(reason: "Use subscriptionOffers instead")
+
introductoryPriceIOS: String
introductoryPriceAsAmountIOS: String
introductoryPricePaymentModeIOS: PaymentModeIOS!
@@ -108,8 +147,12 @@ type ProductSubscriptionIOS implements ProductCommon {
subscriptionPeriodUnitIOS: SubscriptionPeriodIOS
}
-# Discount information returned from the store
-type DiscountIOS {
+"""
+Discount information returned from the store.
+@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+@see https://openiap.dev/docs/types#subscription-offer
+"""
+type DiscountIOS @deprecated(reason: "Use SubscriptionOffer type instead") {
identifier: String!
type: String!
numberOfPeriods: Int!
@@ -334,8 +377,13 @@ type SubscriptionStatusIOS {
renewalInfo: RenewalInfoIOS
}
-# iOS DiscountOffer (output type)
-type DiscountOfferIOS {
+"""
+iOS DiscountOffer (output type).
+@deprecated Use the standardized SubscriptionOffer type instead for cross-platform compatibility.
+@see https://openiap.dev/docs/types#subscription-offer
+"""
+type DiscountOfferIOS
+ @deprecated(reason: "Use SubscriptionOffer type instead") {
"""
Discount identifier
"""
diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql
index f7fd0b0e..d183cdcb 100644
--- a/packages/gql/src/type.graphql
+++ b/packages/gql/src/type.graphql
@@ -419,6 +419,300 @@ type ActiveSubscription {
renewalInfoIOS: RenewalInfoIOS
}
+# =============================================================================
+# Standardized Discount/Offer Types (Cross-Platform)
+# =============================================================================
+# These types provide a unified interface for discount and offer handling
+# across iOS and Android platforms. Platform-specific fields use suffixes.
+# =============================================================================
+
+"""
+Discount offer type enumeration.
+Categorizes the type of discount or promotional offer.
+"""
+enum DiscountOfferType {
+ """
+ Introductory offer for new subscribers (first-time purchase discount)
+ """
+ Introductory
+ """
+ Promotional offer for existing or returning subscribers
+ """
+ Promotional
+ """
+ One-time product discount (Android only, Google Play Billing 7.0+)
+ """
+ OneTime
+}
+
+"""
+Payment mode for subscription offers.
+Determines how the user pays during the offer period.
+"""
+enum PaymentMode {
+ """
+ Free trial period - no charge during offer
+ """
+ FreeTrial
+ """
+ Pay each period at reduced price
+ """
+ PayAsYouGo
+ """
+ Pay full discounted amount upfront
+ """
+ PayUpFront
+ """
+ Unknown or unspecified payment mode
+ """
+ Unknown
+}
+
+"""
+Subscription period unit for cross-platform use.
+"""
+enum SubscriptionPeriodUnit {
+ Day
+ Week
+ Month
+ Year
+ Unknown
+}
+
+"""
+Subscription period value combining unit and count.
+"""
+type SubscriptionPeriod {
+ """
+ The period unit (day, week, month, year)
+ """
+ unit: SubscriptionPeriodUnit!
+ """
+ The number of units (e.g., 1 for monthly, 3 for quarterly)
+ """
+ value: Int!
+}
+
+"""
+Standardized one-time product discount offer.
+Provides a unified interface for one-time purchase discounts across platforms.
+
+Currently supported on Android (Google Play Billing 7.0+).
+iOS does not support one-time purchase discounts in the same way.
+
+@see https://openiap.dev/docs/features/discount
+"""
+type DiscountOffer {
+ """
+ Unique identifier for the offer.
+ - iOS: Not applicable (one-time discounts not supported)
+ - Android: offerId from ProductAndroidOneTimePurchaseOfferDetail
+ """
+ id: ID
+
+ """
+ Formatted display price string (e.g., "$4.99")
+ """
+ displayPrice: String!
+
+ """
+ Numeric price value
+ """
+ price: Float!
+
+ """
+ Currency code (ISO 4217, e.g., "USD")
+ """
+ currency: String!
+
+ """
+ Type of discount offer
+ """
+ type: DiscountOfferType!
+
+ # ---------------------------------------------------------------------------
+ # Android-specific fields (suffix: Android)
+ # ---------------------------------------------------------------------------
+
+ """
+ [Android] Offer token required for purchase.
+ Must be passed to requestPurchase() when purchasing with this offer.
+ """
+ offerTokenAndroid: String
+
+ """
+ [Android] List of tags associated with this offer.
+ """
+ offerTagsAndroid: [String!]
+
+ """
+ [Android] Original full price in micro-units before discount.
+ Divide by 1,000,000 to get the actual price.
+ Use for displaying strikethrough original price.
+ """
+ fullPriceMicrosAndroid: String
+
+ """
+ [Android] Percentage discount (e.g., 33 for 33% off).
+ Only present for percentage-based discounts.
+ """
+ percentageDiscountAndroid: Int
+
+ """
+ [Android] Fixed discount amount in micro-units.
+ Only present for fixed amount discounts.
+ """
+ discountAmountMicrosAndroid: String
+
+ """
+ [Android] Formatted discount amount string (e.g., "$5.00 OFF").
+ """
+ formattedDiscountAmountAndroid: String
+
+ """
+ [Android] Valid time window for the offer.
+ Contains startTimeMillis and endTimeMillis.
+ """
+ validTimeWindowAndroid: ValidTimeWindowAndroid
+
+ """
+ [Android] Limited quantity information.
+ Contains maximumQuantity and remainingQuantity.
+ """
+ limitedQuantityInfoAndroid: LimitedQuantityInfoAndroid
+
+ """
+ [Android] Pre-order details if this is a pre-order offer.
+ Available in Google Play Billing Library 8.1.0+
+ """
+ preorderDetailsAndroid: PreorderDetailsAndroid
+
+ """
+ [Android] Rental details if this is a rental offer.
+ """
+ rentalDetailsAndroid: RentalDetailsAndroid
+}
+
+"""
+Standardized subscription discount/promotional offer.
+Provides a unified interface for subscription offers across iOS and Android.
+
+Both platforms support subscription offers with different implementations:
+- iOS: Introductory offers, promotional offers with server-side signatures
+- Android: Offer tokens with pricing phases
+
+@see https://openiap.dev/docs/types/ios#discount-offer
+@see https://openiap.dev/docs/types/android#subscription-offer
+"""
+type SubscriptionOffer {
+ """
+ Unique identifier for the offer.
+ - iOS: Discount identifier from App Store Connect
+ - Android: offerId from ProductSubscriptionAndroidOfferDetails
+ """
+ id: ID!
+
+ """
+ Formatted display price string (e.g., "$9.99/month")
+ """
+ displayPrice: String!
+
+ """
+ Numeric price value
+ """
+ price: Float!
+
+ """
+ Currency code (ISO 4217, e.g., "USD")
+ """
+ currency: String
+
+ """
+ Type of subscription offer (Introductory or Promotional)
+ """
+ type: DiscountOfferType!
+
+ """
+ Subscription period for this offer
+ """
+ period: SubscriptionPeriod
+
+ """
+ Number of periods the offer applies
+ """
+ periodCount: Int
+
+ """
+ Payment mode during the offer period
+ """
+ paymentMode: PaymentMode
+
+ # ---------------------------------------------------------------------------
+ # iOS-specific fields (suffix: IOS)
+ # ---------------------------------------------------------------------------
+
+ """
+ [iOS] Key identifier for signature validation.
+ Used with server-side signature generation for promotional offers.
+ """
+ keyIdentifierIOS: String
+
+ """
+ [iOS] Cryptographic nonce (UUID) for signature validation.
+ Must be generated server-side for each purchase attempt.
+ """
+ nonceIOS: String
+
+ """
+ [iOS] Server-generated signature for promotional offer validation.
+ Required when applying promotional offers on iOS.
+ """
+ signatureIOS: String
+
+ """
+ [iOS] Timestamp when the signature was generated.
+ Used for signature validation.
+ """
+ timestampIOS: Float
+
+ """
+ [iOS] Number of billing periods for this discount.
+ """
+ numberOfPeriodsIOS: Int
+
+ """
+ [iOS] Localized price string.
+ """
+ localizedPriceIOS: String
+
+ # ---------------------------------------------------------------------------
+ # Android-specific fields (suffix: Android)
+ # ---------------------------------------------------------------------------
+
+ """
+ [Android] Base plan identifier.
+ Identifies which base plan this offer belongs to.
+ """
+ basePlanIdAndroid: String
+
+ """
+ [Android] Offer token required for purchase.
+ Must be passed to requestPurchase() when purchasing with this offer.
+ """
+ offerTokenAndroid: String
+
+ """
+ [Android] List of tags associated with this offer.
+ """
+ offerTagsAndroid: [String!]
+
+ """
+ [Android] Pricing phases for this subscription offer.
+ Contains detailed pricing information for each phase (trial, intro, regular).
+ """
+ pricingPhasesAndroid: PricingPhasesAndroid
+}
+
# Initialization configuration
"""
Connection initialization configuration