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() { @@ -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 + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    + 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 TypeNew TypeNotes
    + 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