diff --git a/.github/workflows/ci-gemini-pr.yml b/.github/workflows/ci-gemini-pr.yml index 2acb02c53..c2b3c8782 100644 --- a/.github/workflows/ci-gemini-pr.yml +++ b/.github/workflows/ci-gemini-pr.yml @@ -7,7 +7,6 @@ on: issue_comment: types: [created] -# Ensure only one run per-issue at a time and avoid duplicates concurrency: group: ci-gemini-pr-${{ github.event.issue.number }} cancel-in-progress: true @@ -16,6 +15,7 @@ permissions: contents: write issues: write pull-requests: write + id-token: write jobs: analyze-and-pr: @@ -46,7 +46,12 @@ jobs: id: analyze-pr uses: google-github-actions/run-gemini-cli@v0 timeout-minutes: 20 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + # Code Assist settings + use_gemini_code_assist: true + gemini_model: "gemini-2.5-pro" prompt: | You are an expert Flutter developer helping with the flutter_inapp_purchase package. @@ -91,36 +96,17 @@ jobs: settings: | { "tools": [ - { - "name": "gh", - "description": "GitHub CLI for creating branches and PRs" - }, - { - "name": "git", - "description": "Git commands for version control" - }, - { - "name": "flutter", - "description": "Flutter CLI for testing and analysis" - }, - { - "name": "dart", - "description": "Dart CLI for formatting and analysis" - } + { "name": "gh", "description": "GitHub CLI for creating branches and PRs" }, + { "name": "git", "description": "Git commands for version control" }, + { "name": "flutter", "description": "Flutter CLI for testing and analysis" }, + { "name": "dart", "description": "Dart CLI for formatting and analysis" } ], "repositories": [ - { - "path": ".", - "instructions": "Refer to CLAUDE.md for conventions. Keep changes minimal, add tests, and follow pre-commit checks." - } + { "path": ".", "instructions": "Refer to CLAUDE.md for conventions. Keep changes minimal, add tests, and follow pre-commit checks." } ], "model": "gemini-1.5-flash" } - gemini_api_key: ${{ secrets.GEMINI_API_KEY }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Comment on issue with result if: always() uses: actions/github-script@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 720871af4..a3c8f0492 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,15 @@ jobs: - run: flutter pub get - - run: dart format --page-width 80 --trailing-commas automate --output=none --set-exit-if-changed . + - name: Check formatting (excluding generated files) + shell: bash + run: | + set -euo pipefail + files=$(git ls-files '*.dart' | grep -Ev '^lib/types\.dart$|\.g\.dart$|\.freezed\.dart$' || true) + if [[ -z "$files" ]]; then + exit 0 + fi + printf '%s\n' "$files" | xargs dart format --page-width 80 --output=none --set-exit-if-changed # - run: flutter analyze . diff --git a/.gitignore b/.gitignore index ab174a08d..8631fc718 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .pub/ pubspec.lock +.build/ build/ .claude/ @@ -38,4 +39,4 @@ docs/node_modules/ # Android build artifacts example/android/app/.cxx/ -*.cxx/ +*.cxx/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 559c87867..8ba4e20f8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,9 @@ // Dart & Flutter settings "dart.flutterSdkPath": null, + "dart.doNotFormat": [ + "lib/types.dart" + ], "[dart]": { "editor.rulers": [80], "editor.selectionHighlight": false, @@ -62,5 +65,5 @@ // Docusaurus specific "typescript.tsdk": "docs/node_modules/typescript/lib", "npm.packageManager": "npm", - "cSpell.words": ["inapp", "Inapp"] + "cSpell.words": ["inapp", "Inapp", "skus"] } diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 80518aa3b..a87fb5e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 6.7.0 + +- fix(android): honor subscription offerToken +- refactor: align openiap-gql@1.0.2 https://github.com/hyodotdev/openiap-gql/releases/tag/1.0.2 +- migration: integrate `openiap-google@1.1.11` +- migration: integrate `openiap-apple@1.1.12` + ## 6.6.1 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 5f4eefb4a..ae42a7a74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,12 @@ final allPurchases = await iap.getAvailablePurchases( ## Flutter-Specific Guidelines +### Generated Files + +- `lib/types.dart` is generated from the OpenIAP schema. Never edit it by hand. +- Always regenerate via `./scripts/generate-type.sh` so the file stays in sync with the upstream `openiap-dart` package. +- If the generation script fails, fix the script or the upstream source instead of patching the output manually. + ### Documentation Style - **Avoid using emojis** in documentation, especially in headings @@ -53,9 +59,9 @@ final allPurchases = await iap.getAvailablePurchases( Before committing any changes, run these commands in order and ensure ALL pass: -1. **Format check**: `dart format --set-exit-if-changed .` - - This will fail if any files need formatting (exit code 1) - - If it fails, run `dart format .` to fix formatting, then retry +1. **Format check**: `git ls-files '*.dart' | grep -v '^lib/types.dart$' | xargs dart format --page-width 80 --output=none --set-exit-if-changed` + - This matches the CI formatter and skips the generated `lib/types.dart` + - If it fails, run the same command without `--set-exit-if-changed` (or drop the `--output` flag) to auto-format, then retry - Always format code before committing to maintain consistent style 2. **Lint check**: `flutter analyze` - Fix any lint issues before committing @@ -65,6 +71,12 @@ Before committing any changes, run these commands in order and ensure ALL pass: 4. **Final verification**: Re-run `dart format --set-exit-if-changed .` to confirm no formatting issues 5. Only commit if ALL checks succeed with exit code 0 +### Commit Message Convention + +- Follow the Angular commit style: `: ` (50 characters max). +- Use lowercase `type` (e.g., `feat`, `fix`, `docs`, `chore`, `test`). +- Keep the summary concise and descriptive; avoid punctuation at the end. + **Important**: - Use `--set-exit-if-changed` flag to match CI behavior and catch formatting issues locally before they cause CI failures - When using generic functions like `showModalBottomSheet`, always specify explicit type arguments (e.g., `showModalBottomSheet`) to avoid type inference errors diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f672c2ec..b8665c5d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ flutter run By default, this plugin depends on the published artifact: ``` -implementation "io.github.hyochan.openiap:openiap-google:1.1.0" +implementation "io.github.hyochan.openiap:openiap-google:1.1.11" ``` If you need to debug against a local checkout of the OpenIAP Android module: @@ -127,9 +127,9 @@ If you need to debug against a local checkout of the OpenIAP Android module: Edit `android/build.gradle` dependencies to use the local project in debug only: ``` - // implementation "io.github.hyochan.openiap:openiap-google:1.1.0" + // implementation "io.github.hyochan.openiap:openiap-google:1.1.11" debugImplementation project(":openiap") - releaseImplementation "io.github.hyochan.openiap:openiap-google:1.1.0" + releaseImplementation "io.github.hyochan.openiap:openiap-google:1.1.11" ``` 4. Sync and run @@ -137,7 +137,7 @@ If you need to debug against a local checkout of the OpenIAP Android module: Run a Gradle sync from Android Studio or rebuild the Flutter module. To revert, comment out the include lines in `settings.gradle` and restore the single - `implementation "io.github.hyochan.openiap:openiap-google:1.1.0"` line in `android/build.gradle`. + `implementation "io.github.hyochan.openiap:openiap-google:1.1.11"` line in `android/build.gradle`. ### 5. Commit Your Changes diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 9f75e6d9c..000000000 --- a/GEMINI.md +++ /dev/null @@ -1,106 +0,0 @@ -# Gemini Configuration for flutter_inapp_purchase - -This file provides context and instructions for the Gemini CLI when working with this repository. - -## Project Overview - -This is a Flutter plugin for In-App Purchases (IAP) that provides a unified API for both iOS and Android platforms. The plugin follows the OpenIAP specification for standardized IAP implementation. - -## Key Guidelines - -### Code Standards - -1. **API Design**: Follow the simplified API design where methods use direct parameters instead of parameter objects (see CLAUDE.md for details) -2. **Platform Naming**: Use consistent platform suffixes (IOS for iOS, Android for Android) -3. **Testing**: All changes must pass `flutter test` -4. **Formatting**: Code must be formatted with `dart format` -5. **Linting**: Code should pass `flutter analyze` - -### Before Making Changes - -1. Read the existing CLAUDE.md file for detailed implementation guidelines -2. Check existing code patterns in similar files -3. Ensure backward compatibility unless breaking changes are explicitly requested -4. Follow the OpenIAP specification: - -### PR Creation Guidelines - -When creating PRs to fix issues: - -1. **Branch Naming**: Use `fix-issue-` for bug fixes, `feat-issue-` for features -2. **Commit Messages**: Be descriptive and reference the issue number -3. **Testing**: Add tests for new functionality or bug fixes -4. **Documentation**: Update relevant documentation if API changes are made -5. **Verification**: Run all checks before creating the PR: - - `dart format --set-exit-if-changed .` - - `flutter analyze` - - `flutter test` - -### Issue Analysis - -When analyzing issues for automatic resolution: - -1. **Solvable Issues**: - - - Clear bug reports with reproducible steps - - Simple feature requests with well-defined scope - - Documentation updates - - Code formatting or linting issues - - Missing type annotations or small refactoring tasks - -2. **Non-Solvable Issues** (require human intervention): - - Major architectural changes - - Breaking API changes without clear migration path - - Issues requiring business logic decisions - - Platform-specific issues requiring device testing - - Issues with insufficient information - -### Code Modification Rules - -1. **Preserve Existing Patterns**: Match the coding style of surrounding code -2. **Minimal Changes**: Make the smallest change necessary to fix the issue -3. **Test Coverage**: Ensure new code is covered by tests -4. **Error Handling**: Add appropriate error handling for edge cases -5. **Comments**: Add comments only for complex logic that isn't self-explanatory - -### Common Tasks - -#### Adding a New Feature - -1. Check if it aligns with OpenIAP specification -2. Implement for both iOS and Android platforms if applicable -3. Add comprehensive tests -4. Update example app if needed -5. Document in README or API docs - -#### Fixing a Bug - -1. Reproduce the issue first -2. Write a failing test that demonstrates the bug -3. Fix the bug -4. Ensure the test now passes -5. Run all existing tests to ensure no regression - -#### Updating Dependencies - -1. Check compatibility with Flutter stable channel -2. Update pubspec.yaml -3. Run `flutter pub get` -4. Test thoroughly -5. Update CHANGELOG.md - -## Repository Structure - -- `/lib`: Main plugin implementation -- `/android`: Android platform-specific code -- `/ios`: iOS platform-specific code -- `/example`: Example Flutter app demonstrating plugin usage -- `/test`: Unit and integration tests -- `/docs`: Additional documentation - -## Important Files - -- `CLAUDE.md`: Detailed implementation guidelines and conventions -- `pubspec.yaml`: Package dependencies and metadata -- `CHANGELOG.md`: Version history and changes -- `.github/workflows/ci.yml`: CI pipeline configuration diff --git a/analysis_options.yaml b/analysis_options.yaml index b16010925..8b27fd81d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,5 @@ -include: package:flutter_lints/flutter.yaml +# Local copy of flutter_lints 3.0.2 to avoid package resolution issues in CI. +include: tool/lints/flutter.yaml linter: rules: @@ -31,7 +32,10 @@ linter: - use_rethrow_when_possible analyzer: + exclude: + - lib/types.dart + - example/** language: strict-casts: true strict-inference: true - strict-raw-types: true \ No newline at end of file + strict-raw-types: true diff --git a/android/build.gradle b/android/build.gradle index f981a921b..228a84b4f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -52,7 +52,7 @@ android { dependencies { // OpenIAP Google billing wrapper (1.1.0) includes Google Play Billing - implementation "io.github.hyochan.openiap:openiap-google:1.1.0" + implementation "io.github.hyochan.openiap:openiap-google:1.1.11" // For local debugging with a checked-out module, you can switch to: // debugImplementation project(":openiap") diff --git a/android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt b/android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt index 2e31b45e3..5c9238b9d 100644 --- a/android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt +++ b/android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt @@ -11,7 +11,9 @@ import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.models.ProductRequest -import dev.hyo.openiap.models.RequestPurchaseAndroidProps +import dev.hyo.openiap.models.RequestPurchaseParams +import dev.hyo.openiap.models.RequestSubscriptionAndroidProps +import dev.hyo.openiap.models.RequestSubscriptionAndroidProps.SubscriptionOffer import dev.hyo.openiap.models.DeepLinkOptions import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -46,6 +48,31 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // OpenIAP module instance private var openIap: OpenIapModule? = null + private fun legacyErrorJson( + code: String, + defaultMessage: String, + message: String? = null, + productId: String? = null + ): JSONObject { + val payload = mutableMapOf( + "code" to code, + "message" to (message ?: defaultMessage) + ) + if (productId != null) { + payload["productId"] = productId + } + return JSONObject(payload) + } + + private fun MethodResultWrapper.error( + code: String, + defaultMessage: String, + message: String? = null + ) { + val resolvedMessage = message ?: defaultMessage + this.error(code, resolvedMessage, null) + } + fun setContext(context: Context?) { this.context = context if (context != null && openIap == null) { @@ -89,7 +116,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val ch = channel if (ch == null) { Log.e(TAG, "onMethodCall received for ${call.method} but channel is null. Cannot send result.") - result.error("E_CHANNEL_NULL", "MethodChannel is not attached", null) + result.error(OpenIapError.DeveloperError.CODE, "MethodChannel is not attached", null) return } val safe = MethodResultWrapper(result, ch) @@ -108,7 +135,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act openIap?.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = packageName)) safe.success(true) } catch (e: Exception) { - safe.error("manageSubscription", e.message, null) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } return @@ -119,7 +146,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act openIap?.deepLinkToSubscriptions(DeepLinkOptions()) safe.success(true) } catch (e: Exception) { - safe.error("openPlayStoreSubscriptions", e.message, null) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } return @@ -142,9 +169,13 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // Emit connection-updated for compatibility val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) - if (ok) safe.success("Billing client ready") else safe.error(call.method, "responseCode: -1", "") + if (ok) { + safe.success("Billing client ready") + } else { + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "responseCode: -1") + } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, e.message) + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, e.message) } } return @@ -156,7 +187,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act connectionReady = false safe.success("Billing client has ended.") } catch (e: Exception) { - safe.error("endConnection", e.message, null) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } return @@ -208,19 +239,19 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val products = iap.fetchProducts(ProductRequest(skuArr, reqType)) @@ -232,7 +263,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_QUERY_PRODUCT, e.message) + safe.error(OpenIapError.QueryProduct.CODE, OpenIapError.QueryProduct.MESSAGE, e.message) } } } @@ -251,26 +282,26 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val purchases = iap.getAvailablePurchases(null) val arr = JSONArray(purchases.map { it.toJSON() }) safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -296,9 +327,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (skusNormalized.isEmpty()) { channel?.invokeMethod( "purchase-error", - JSONObject(OpenIapError.EmptySkuList.toJSON()).toString() + legacyErrorJson(OpenIapError.EmptySkuList.CODE, OpenIapError.EmptySkuList.MESSAGE, "Empty SKUs provided").toString() ) - safe.error(call.method, OpenIapError.E_EMPTY_SKU_LIST, "Empty SKUs provided") + safe.error(OpenIapError.EmptySkuList.CODE, OpenIapError.EmptySkuList.MESSAGE, "Empty SKUs provided") return } @@ -314,46 +345,58 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - val err = OpenIapError.InitConnection("Failed to initialize connection").toJSON() - channel?.invokeMethod("purchase-error", JSONObject(err).toString()) - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + val err = legacyErrorJson(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") + channel?.invokeMethod("purchase-error", err.toString()) + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@withLock } } } catch (e: Exception) { - val err = OpenIapError.BillingError(e.message ?: "Service error").toJSON() - channel?.invokeMethod("purchase-error", JSONObject(err).toString()) - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + val err = legacyErrorJson(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + channel?.invokeMethod("purchase-error", err.toString()) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@withLock } } - try { - val iap = openIap - if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") - return@launch - } - iap.requestPurchase( - RequestPurchaseAndroidProps( - skus = skusNormalized, - obfuscatedAccountIdAndroid = obfuscatedAccountId, - obfuscatedProfileIdAndroid = obfuscatedProfileId, - isOfferPersonalized = isOfferPersonalized, - ), - ProductRequest.ProductRequestType.fromString(typeStr) - ) - // Success signaled by purchase-updated event - safe.success(null) - } catch (e: Exception) { - channel?.invokeMethod( - "purchase-error", - JSONObject(OpenIapError.PurchaseFailed(e.message ?: "Purchase failed").toJSON()).toString() - ) - safe.error(call.method, OpenIapError.E_PURCHASE_ERROR, e.message) + try { + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + val offers = (params["subscriptionOffers"] as? List<*>)?.mapNotNull { entry -> + val map = entry as? Map<*, *> ?: return@mapNotNull null + val sku = map["sku"] as? String ?: return@mapNotNull null + val offerToken = map["offerToken"] as? String ?: return@mapNotNull null + SubscriptionOffer(sku = sku, offerToken = offerToken) } + + val offerList = offers ?: emptyList() + + val requestParams = RequestPurchaseParams( + skus = skusNormalized, + obfuscatedAccountIdAndroid = obfuscatedAccountId, + obfuscatedProfileIdAndroid = obfuscatedProfileId, + isOfferPersonalized = isOfferPersonalized, + subscriptionOffers = offerList + ) + + iap.requestPurchase( + requestParams, + ProductRequest.ProductRequestType.fromString(typeStr) + ) + // Success signaled by purchase-updated event + safe.success(null) + } catch (e: Exception) { + channel?.invokeMethod( + "purchase-error", + legacyErrorJson(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message).toString() + ) + safe.error(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message) } } + } // ----------------------------------------------------------------- // Android-suffix stable APIs (kept) @@ -363,13 +406,13 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val code = iap.getStorefront() safe.success(code) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -382,34 +425,34 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } iap.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = pkg)) safe.success(null) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } "acknowledgePurchaseAndroid" -> { val token = call.argument("token") ?: call.argument("purchaseToken") if (token.isNullOrBlank()) { - safe.error(call.method, OpenIapError.E_DEVELOPER_ERROR, "Missing purchaseToken") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } scope.launch { try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } iap.acknowledgePurchaseAndroid(token) val resp = JSONObject().apply { put("responseCode", 0) } safe.success(resp.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -417,14 +460,14 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "consumePurchaseAndroid" -> { val token = call.argument("token") ?: call.argument("purchaseToken") if (token.isNullOrBlank()) { - safe.error(call.method, OpenIapError.E_DEVELOPER_ERROR, "Missing purchaseToken") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } scope.launch { try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } iap.consumePurchaseAndroid(token) @@ -434,7 +477,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(resp.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -463,23 +506,23 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val products = iap.fetchProducts( - ProductRequest(productIds, ProductRequest.ProductRequestType.INAPP) + ProductRequest(productIds, ProductRequest.ProductRequestType.InApp) ) val arr = JSONArray() products.forEach { p -> @@ -490,7 +533,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_QUERY_PRODUCT, e.message) + safe.error(OpenIapError.QueryProduct.CODE, OpenIapError.QueryProduct.MESSAGE, e.message) } } } @@ -509,23 +552,23 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val products = iap.fetchProducts( - ProductRequest(productIds, ProductRequest.ProductRequestType.SUBS) + ProductRequest(productIds, ProductRequest.ProductRequestType.Subs) ) val arr = JSONArray() products.forEach { p -> @@ -536,7 +579,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_QUERY_PRODUCT, e.message) + safe.error(OpenIapError.QueryProduct.CODE, OpenIapError.QueryProduct.MESSAGE, e.message) } } } @@ -558,26 +601,26 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val purchases = iap.getAvailableItems(reqType) val arr = JSONArray(purchases.map { it.toJSON() }) safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -597,26 +640,26 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch } } } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@launch } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } val purchases = iap.getAvailableItems(reqType) val arr = JSONArray(purchases.map { it.toJSON() }) safe.success(arr.toString()) } catch (e: Exception) { - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -635,9 +678,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (productId.isNullOrBlank()) { channel?.invokeMethod( "purchase-error", - JSONObject(OpenIapError.BillingError("Missing productId").toJSON()).toString() + legacyErrorJson(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing productId").toString() ) - safe.error("buyItemByType", OpenIapError.E_DEVELOPER_ERROR, "Missing productId") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing productId") return } @@ -653,42 +696,46 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val item = JSONObject().apply { put("connected", ok) } channel?.invokeMethod("connection-updated", item.toString()) if (!ok) { - val err = OpenIapError.InitConnection("Failed to initialize connection").toJSON() - channel?.invokeMethod("purchase-error", JSONObject(err).toString()) - safe.error(call.method, OpenIapError.E_INIT_CONNECTION, "Failed to initialize connection") + val err = legacyErrorJson(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") + channel?.invokeMethod("purchase-error", err.toString()) + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@withLock } } } catch (e: Exception) { - val err = OpenIapError.BillingError(e.message ?: "Service error").toJSON() - channel?.invokeMethod("purchase-error", JSONObject(err).toString()) - safe.error(call.method, OpenIapError.E_SERVICE_ERROR, e.message) + val err = legacyErrorJson(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + channel?.invokeMethod("purchase-error", err.toString()) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) return@withLock } } try { val iap = openIap if (iap == null) { - safe.error(call.method, OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } + val requestParams = RequestPurchaseParams( + skus = listOf(productId), + obfuscatedAccountIdAndroid = obfuscatedAccountId, + obfuscatedProfileIdAndroid = obfuscatedProfileId, + isOfferPersonalized = isOfferPersonalized, + subscriptionOffers = emptyList() + ) + iap.requestPurchase( - RequestPurchaseAndroidProps( - skus = listOf(productId), - obfuscatedAccountIdAndroid = obfuscatedAccountId, - obfuscatedProfileIdAndroid = obfuscatedProfileId, - isOfferPersonalized = isOfferPersonalized, - ), + requestParams, ProductRequest.ProductRequestType.fromString(typeStr) ) + safe.success(null) } catch (e: Exception) { channel?.invokeMethod( "purchase-error", - JSONObject(OpenIapError.PurchaseFailed(e.message ?: "Purchase failed").toJSON()).toString() + legacyErrorJson(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message).toString() ) + safe.error(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message) } } - safe.success(null) } // Finish/acknowledge/consume (compat) @@ -696,21 +743,21 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act logDeprecated("acknowledgePurchase", "Use acknowledgePurchaseAndroid(token) instead") val token = call.argument("purchaseToken") if (token.isNullOrBlank()) { - safe.error("acknowledgePurchase", OpenIapError.E_DEVELOPER_ERROR, "Missing purchaseToken") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } scope.launch { try { val iap = openIap if (iap == null) { - safe.error("acknowledgePurchase", OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } iap.acknowledgePurchaseAndroid(token) val resp = JSONObject().apply { put("responseCode", 0) } safe.success(resp.toString()) } catch (e: Exception) { - safe.error("acknowledgePurchase", OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -720,14 +767,14 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act logDeprecated("consumeProduct", "Use finishTransaction(purchase, isConsumable=true) at higher-level API") val token = call.argument("purchaseToken") if (token.isNullOrBlank()) { - safe.error("consumeProduct", OpenIapError.E_DEVELOPER_ERROR, "Missing purchaseToken") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } scope.launch { try { val iap = openIap if (iap == null) { - safe.error("consumeProduct", OpenIapError.E_NOT_PREPARED, "IAP module not initialized.") + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } iap.consumePurchaseAndroid(token) @@ -737,7 +784,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(resp.toString()) } catch (e: Exception) { - safe.error("consumeProduct", OpenIapError.E_SERVICE_ERROR, e.message) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } } } @@ -745,7 +792,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act logDeprecated("consumePurchase", "Use finishTransaction(purchase, isConsumable=true) at higher-level API") val token = call.argument("purchaseToken") if (token.isNullOrBlank()) { - safe.error("consumePurchase", OpenIapError.E_DEVELOPER_ERROR, "Missing purchaseToken") + safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } scope.launch { @@ -797,11 +844,13 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act try { val payload = when (e) { is OpenIapError -> JSONObject(e.toJSON()) - else -> JSONObject(mapOf( - "code" to OpenIapError.E_PURCHASE_ERROR, - "message" to (e.message ?: "Purchase error"), - "platform" to "android" - )) + else -> JSONObject( + mapOf( + "code" to OpenIapError.PurchaseFailed.CODE, + "message" to (e.message ?: "Purchase error"), + "platform" to "android" + ) + ) } channel?.invokeMethod("purchase-error", payload.toString()) } catch (ex: Exception) { diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index f94181316..7b329e8d5 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace 'dev.hyo.martie' compileSdkVersion 34 - ndkVersion flutter.ndkVersion + ndkVersion "27.0.12077973" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/example/ios/Flutter/Flutter.podspec b/example/ios/Flutter/Flutter.podspec index 3aed58d35..98e163395 100644 --- a/example/ios/Flutter/Flutter.podspec +++ b/example/ios/Flutter/Flutter.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.license = { :type => 'BSD' } s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } - s.ios.deployment_target = '13.0' + s.ios.deployment_target = '12.0' # Framework linking is handled by Flutter tooling, not CocoaPods. # Add a placeholder to satisfy `s.dependency 'Flutter'` plugin podspecs. s.vendored_frameworks = 'path/to/nothing' diff --git a/example/ios/Podfile b/example/ios/Podfile index d715d5683..c496b7160 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -33,7 +33,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! # Ensure openiap resolves to 1.1.9 even if trunk index is lagging - pod 'openiap', :git => 'https://github.com/hyodotdev/openiap-apple.git', :tag => '1.1.9' + pod 'openiap', :git => 'https://github.com/hyodotdev/openiap-apple.git', :tag => '1.1.12' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9252357c7..106940083 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,13 +2,13 @@ PODS: - Flutter (1.0.0) - flutter_inapp_purchase (0.0.1): - Flutter - - openiap (= 1.1.9) - - openiap (1.1.9) + - openiap (= 1.1.12) + - openiap (1.1.12) DEPENDENCIES: - Flutter (from `Flutter`) - flutter_inapp_purchase (from `.symlinks/plugins/flutter_inapp_purchase/ios`) - - openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.1.9`) + - openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.1.12`) EXTERNAL SOURCES: Flutter: @@ -17,18 +17,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_inapp_purchase/ios" openiap: :git: https://github.com/hyodotdev/openiap-apple.git - :tag: 1.1.9 + :tag: 1.1.12 CHECKOUT OPTIONS: openiap: :git: https://github.com/hyodotdev/openiap-apple.git - :tag: 1.1.9 + :tag: 1.1.12 SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_inapp_purchase: 919e64e275a3a68ed9a0b7798c3f51e591a1153f - openiap: dcfa2a4dcf31259ec4b73658e7d1441babccce66 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_inapp_purchase: 8e8e96cc14bc141e3ffd432012fc9d4e347f8f71 + openiap: e207e50cc83dc28cd00c3701421a12eacdbd163a -PODFILE CHECKSUM: 7f77729f2fac4d8b3c65d064abe240d5bd87fe01 +PODFILE CHECKSUM: 9446ef2faab77e656d46b3aaf8938ab324919b93 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/example/lib/src/screens/available_purchases_screen.dart b/example/lib/src/screens/available_purchases_screen.dart index b39dc8ad2..b7bfd09c9 100644 --- a/example/lib/src/screens/available_purchases_screen.dart +++ b/example/lib/src/screens/available_purchases_screen.dart @@ -64,6 +64,14 @@ class _AvailablePurchasesScreenState extends State { return uniquePurchases.values.toList(); } + IapPlatform _platformOrDefault() { + try { + return getCurrentPlatform(); + } catch (_) { + return IapPlatform.Android; + } + } + @override void initState() { super.initState(); @@ -121,7 +129,7 @@ class _AvailablePurchasesScreenState extends State { // Load purchase history List purchaseHistory = []; - if (getCurrentPlatform() == IapPlatform.ios) { + if (_platformOrDefault() == IapPlatform.IOS) { // iOS: include expired subscriptions as history purchaseHistory = await _iap.getAvailablePurchases( const PurchaseOptions(onlyIncludeActiveItemsIOS: false), diff --git a/example/lib/src/screens/builder_demo_screen.dart b/example/lib/src/screens/builder_demo_screen.dart index ccf09080e..62a5ff96d 100644 --- a/example/lib/src/screens/builder_demo_screen.dart +++ b/example/lib/src/screens/builder_demo_screen.dart @@ -39,8 +39,8 @@ class _BuilderDemoScreenState extends State { try { await _iap.requestPurchaseWithBuilder( build: (RequestPurchaseBuilder r) => r - ..type = ProductType.inapp - ..withIOS((RequestPurchaseIOSBuilder i) => + ..type = ProductType.InApp + ..withIOS((RequestPurchaseIosBuilder i) => i..sku = 'dev.hyo.martie.10bulbs') ..withAndroid((RequestPurchaseAndroidBuilder a) => a..skus = ['dev.hyo.martie.10bulbs']), @@ -63,8 +63,8 @@ class _BuilderDemoScreenState extends State { // Use requestPurchaseWithBuilder with type=subs await _iap.requestPurchaseWithBuilder( build: (RequestPurchaseBuilder r) => r - ..type = ProductType.subs - ..withIOS((RequestPurchaseIOSBuilder i) => + ..type = ProductType.Subs + ..withIOS((RequestPurchaseIosBuilder i) => i..sku = 'dev.hyo.martie.premium') ..withAndroid((RequestPurchaseAndroidBuilder a) => a..skus = ['dev.hyo.martie.premium']), @@ -86,15 +86,16 @@ class _BuilderDemoScreenState extends State { try { // Get existing subscription token if any final purchases = await _iap.getAvailablePurchases(); - final existing = purchases.firstWhere( - (p) => p.productId == 'dev.hyo.martie.premium', - orElse: () => purchases.isNotEmpty - ? purchases.first - : Purchase(productId: '', platform: IapPlatform.android), - ); + Purchase? existing; + for (final purchase in purchases) { + if (purchase.productId == 'dev.hyo.martie.premium') { + existing = purchase; + break; + } + } // Android requires an existing purchase token to replace (proration) - final token = existing.purchaseToken; + final token = existing?.purchaseToken; final hasToken = token != null && token.isNotEmpty; // Demo: use a default proration mode for upgrade final int prorationMode = AndroidReplacementMode.withTimeProration.value; @@ -108,8 +109,8 @@ class _BuilderDemoScreenState extends State { ..purchaseTokenAndroid = token); await _iap.requestPurchase( - request: subBuilder.build(), - type: ProductType.subs, + props: subBuilder.build(), + type: ProductType.Subs, ); setState(() => _status = 'Subscription upgrade initiated'); } else { @@ -118,8 +119,8 @@ class _BuilderDemoScreenState extends State { ..withAndroid((RequestSubscriptionAndroidBuilder a) => a..skus = ['dev.hyo.martie.premium']); await _iap.requestPurchase( - request: newSub.build(), - type: ProductType.subs, + props: newSub.build(), + type: ProductType.Subs, ); setState(() => _status = 'No token/proration; purchased yearly as new subscription'); @@ -197,8 +198,8 @@ class _BuilderDemoScreenState extends State { SelectableText( """await iap.requestPurchaseWithBuilder( build: (RequestPurchaseBuilder r) => r - ..type = ProductType.inapp - ..withIOS((RequestPurchaseIOSBuilder i) => i + ..type = ProductType.InApp + ..withIOS((RequestPurchaseIosBuilder i) => i ..sku = 'dev.hyo.martie.10bulbs' ..quantity = 1) ..withAndroid((RequestPurchaseAndroidBuilder a) => a @@ -208,8 +209,8 @@ class _BuilderDemoScreenState extends State { // For subscriptions (new purchase): await iap.requestPurchaseWithBuilder( build: (RequestPurchaseBuilder r) => r - ..type = ProductType.subs - ..withIOS((RequestPurchaseIOSBuilder i) => i..sku = 'dev.hyo.martie.premium') + ..type = ProductType.Subs + ..withIOS((RequestPurchaseIosBuilder i) => i..sku = 'dev.hyo.martie.premium') ..withAndroid((RequestPurchaseAndroidBuilder a) => a..skus = ['dev.hyo.martie.premium']), ); @@ -219,7 +220,7 @@ final b = RequestSubscriptionBuilder() ..skus = ['dev.hyo.martie.premium'] ..replacementModeAndroid = AndroidReplacementMode.withTimeProration.value ..purchaseTokenAndroid = ''); -await iap.requestPurchase(request: b.build(), type: ProductType.subs);""", +await iap.requestPurchase(request: b.build(), type: ProductType.Subs);""", style: TextStyle(fontFamily: 'monospace', fontSize: 12), ), ], diff --git a/example/lib/src/screens/error_handling_example.dart b/example/lib/src/screens/error_handling_example.dart index 8ca1dc88d..bcf43f6d3 100644 --- a/example/lib/src/screens/error_handling_example.dart +++ b/example/lib/src/screens/error_handling_example.dart @@ -171,7 +171,7 @@ class ErrorHandlingExample extends StatelessWidget { final error = PurchaseError( code: ErrorCode.UserCancelled, message: 'User cancelled the purchase', - platform: IapPlatform.ios, + platform: IapPlatform.IOS, ); return ''' @@ -190,7 +190,7 @@ isRecoverableError: ${isRecoverableError(error)} final error = PurchaseError( code: ErrorCode.NetworkError, message: 'Network connection failed', - platform: IapPlatform.android, + platform: IapPlatform.Android, ); return ''' @@ -249,7 +249,7 @@ isRecoverableError: ${isRecoverableError(error)} final error = PurchaseError( code: ErrorCode.NetworkError, message: 'Network error occurred', - platform: IapPlatform.ios, + platform: IapPlatform.IOS, ); return ''' @@ -324,7 +324,7 @@ class PurchaseWithErrorHandling extends StatelessWidget { throw PurchaseError( code: ErrorCode.NetworkError, message: 'Failed to connect to store', - platform: IapPlatform.ios, + platform: IapPlatform.IOS, ); } catch (error) { // Handle the error using our utilities diff --git a/example/lib/src/screens/purchase_flow_screen.dart b/example/lib/src/screens/purchase_flow_screen.dart index 076a47244..21c64cb5d 100644 --- a/example/lib/src/screens/purchase_flow_screen.dart +++ b/example/lib/src/screens/purchase_flow_screen.dart @@ -123,7 +123,7 @@ class _PurchaseFlowScreenState extends State { debugPrint(' Purchase token: ${purchase.purchaseToken}'); debugPrint(' ID: ${purchase.id} (${purchase.id.runtimeType})'); debugPrint(' IDs array: ${purchase.ids}'); - if (purchase.platform == IapPlatform.ios) { + if (purchase.platform == IapPlatform.IOS) { debugPrint(' quantityIOS: ${purchase.quantityIOS}'); debugPrint( ' originalTransactionIdentifierIOS: ${purchase.originalTransactionIdentifierIOS} (${purchase.originalTransactionIdentifierIOS?.runtimeType})'); @@ -148,18 +148,19 @@ class _PurchaseFlowScreenState extends State { if (Platform.isAndroid) { // For Android, check multiple conditions since fields can be null - bool condition1 = purchase.purchaseState == PurchaseState.purchased; - bool condition2 = (purchase.isAcknowledgedAndroid == false && + final bool condition1 = purchase.purchaseState == PurchaseState.Purchased; + final bool condition2 = purchase.isAcknowledgedAndroid == false && purchase.purchaseToken != null && - purchase.purchaseToken!.isNotEmpty); - bool condition3 = - purchase.purchaseStateAndroid == AndroidPurchaseState.purchased.value; + purchase.purchaseToken!.isNotEmpty && + purchase.purchaseStateAndroid == AndroidPurchaseState.Purchased.value; + final bool condition3 = + purchase.purchaseStateAndroid == AndroidPurchaseState.Purchased.value; debugPrint(' Android condition checks:'); debugPrint(' purchaseState == purchased: $condition1'); debugPrint(' unacknowledged with token: $condition2'); debugPrint( - ' purchaseStateAndroid == AndroidPurchaseState.purchased: $condition3'); + ' purchaseStateAndroid == AndroidPurchaseState.Purchased: $condition3'); isPurchased = condition1 || condition2 || condition3; debugPrint(' Final isPurchased: $isPurchased'); @@ -332,7 +333,7 @@ Platform: ${error.platform} // Use fetchProducts with Product type for type-safe list final products = await _iap.fetchProducts( skus: productIds, - type: ProductType.inapp, + type: ProductType.InApp, ); debugPrint( @@ -386,8 +387,8 @@ Platform: ${error.platform} android: RequestPurchaseAndroid( skus: [productId], ), + type: ProductType.InApp, ), - type: ProductType.inapp, ); debugPrint('✅ Purchase request sent successfully'); diff --git a/example/lib/src/screens/subscription_flow_screen.dart b/example/lib/src/screens/subscription_flow_screen.dart index 15eedd246..d846b782b 100644 --- a/example/lib/src/screens/subscription_flow_screen.dart +++ b/example/lib/src/screens/subscription_flow_screen.dart @@ -94,22 +94,25 @@ class _SubscriptionFlowScreenState extends State { } // Handle the purchase - check multiple conditions - // purchaseState.purchased or purchaseStateAndroid == AndroidPurchaseState.purchased or isAcknowledgedAndroid == false (new purchase) + // purchaseState.purchased or purchaseStateAndroid == AndroidPurchaseState.Purchased or isAcknowledgedAndroid == false (new purchase) bool isPurchased = false; if (Platform.isAndroid) { // For Android, check multiple conditions since fields can be null - bool condition1 = purchase.purchaseState == PurchaseState.purchased; - bool condition2 = (purchase.isAcknowledgedAndroid == false && + bool condition1 = purchase.purchaseState == PurchaseState.Purchased; + bool condition2 = purchase.isAcknowledgedAndroid == false && purchase.purchaseToken != null && - purchase.purchaseToken!.isNotEmpty); + purchase.purchaseToken!.isNotEmpty && + purchase.purchaseStateAndroid == + AndroidPurchaseState.Purchased.value; bool condition3 = purchase.purchaseStateAndroid == - AndroidPurchaseState.purchased.value; + AndroidPurchaseState.Purchased.value; debugPrint(' Android condition checks:'); debugPrint(' purchaseState == purchased: $condition1'); debugPrint(' unacknowledged with token: $condition2'); - debugPrint(' purchaseStateAndroid == purchased: $condition3'); + debugPrint( + ' purchaseStateAndroid == AndroidPurchaseState.Purchased: $condition3'); isPurchased = condition1 || condition2 || condition3; debugPrint(' Final isPurchased: $isPurchased'); @@ -169,9 +172,9 @@ class _SubscriptionFlowScreenState extends State { debugPrint('Refreshing subscriptions...'); await _checkActiveSubscriptions(); debugPrint('Subscriptions refreshed'); - } else if (purchase.purchaseState == PurchaseState.pending || + } else if (purchase.purchaseState == PurchaseState.Pending || purchase.purchaseStateAndroid == - AndroidPurchaseState.unspecified.value) { + AndroidPurchaseState.Unknown.value) { // Pending if (!mounted) return; setState(() { @@ -270,7 +273,7 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt // Use fetchProducts with Subscription type for type-safe list final products = await _iap.fetchProducts( skus: subscriptionIds, - type: ProductType.subs, + type: ProductType.Subs, ); debugPrint('Loaded ${products.length} subscriptions'); @@ -392,12 +395,10 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt purchaseTokenAndroid: _currentSubscription!.purchaseToken, replacementModeAndroid: _selectedProrationMode, ), + type: ProductType.Subs, ); - await _iap.requestPurchase( - request: request, - type: ProductType.subs, - ); + await _iap.requestPurchase(request: request); } else { // This is a new subscription purchase debugPrint('Purchasing new subscription'); @@ -407,12 +408,10 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt skus: [item.id], subscriptionOffers: selectedOffer != null ? [selectedOffer] : [], ), + type: ProductType.Subs, ); - await _iap.requestPurchase( - request: request, - type: ProductType.subs, - ); + await _iap.requestPurchase(request: request); } } else { // iOS @@ -420,12 +419,10 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt ios: RequestPurchaseIOS( sku: item.id, ), + type: ProductType.Subs, ); - await _iap.requestPurchase( - request: request, - type: ProductType.subs, - ); + await _iap.requestPurchase(request: request); } // Result will be handled by the purchase stream listeners @@ -468,12 +465,10 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt fakeToken, // Fake token that will fail on native side replacementModeAndroid: AndroidReplacementMode.deferred.value, ), + type: ProductType.Subs, ); - await _iap.requestPurchase( - request: request, - type: ProductType.subs, - ); + await _iap.requestPurchase(request: request); // If we get here, the purchase was attempted debugPrint('Purchase request sent with fake token'); @@ -515,12 +510,10 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt purchaseTokenAndroid: testToken, // Use test token to pass validation replacementModeAndroid: AndroidReplacementMode.deferred.value, ), + type: ProductType.Subs, ); - await _iap.requestPurchase( - request: request, - type: ProductType.subs, - ); + await _iap.requestPurchase(request: request); debugPrint('Purchase request sent with test token'); // Result will come through purchaseUpdatedListener diff --git a/example/lib/src/widgets/product_detail_modal.dart b/example/lib/src/widgets/product_detail_modal.dart index 3b7025260..01dfc3354 100644 --- a/example/lib/src/widgets/product_detail_modal.dart +++ b/example/lib/src/widgets/product_detail_modal.dart @@ -303,8 +303,10 @@ class ProductDetailModal extends StatelessWidget { discount.localizedPrice), _buildDetailRow( 'Type', discount.type), - _buildDetailRow('Payment Mode', - discount.paymentMode), + _buildDetailRow( + 'Payment Mode', + discount.paymentMode.toJson(), + ), ], ), ), @@ -341,8 +343,10 @@ class ProductDetailModal extends StatelessWidget { discount.localizedPrice), _buildDetailRow( 'Type', discount.type), - _buildDetailRow('Payment Mode', - discount.paymentMode), + _buildDetailRow( + 'Payment Mode', + discount.paymentMode.toJson(), + ), ], ), ), diff --git a/example/lib/test_storekit.dart b/example/lib/test_storekit.dart index 2eea23789..1b9e20fc3 100644 --- a/example/lib/test_storekit.dart +++ b/example/lib/test_storekit.dart @@ -58,7 +58,7 @@ class _TestScreenState extends State { setState(() => _status = 'Getting products...'); final products = await _iap.fetchProducts( skus: ['dev.hyo.martie.10bulbs'], - type: ProductType.inapp, + type: ProductType.InApp, ); setState(() => _status = 'Got ${products.length} products'); diff --git a/example/test/available_purchases_screen_test.dart b/example/test/available_purchases_screen_test.dart new file mode 100644 index 000000000..01ba9a538 --- /dev/null +++ b/example/test/available_purchases_screen_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase_example/src/screens/available_purchases_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + + setUp(() { + channel.setMockMethodCallHandler((MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getAvailablePurchases': + return >[ + { + 'platform': 'android', + 'id': 'txn_123', + 'productId': 'dev.hyo.martie.premium', + 'purchaseToken': 'token_abc', + 'purchaseStateAndroid': 1, + 'isAutoRenewing': true, + 'autoRenewingAndroid': true, + 'transactionDate': 1700000000000.0, + }, + ]; + case 'getPurchaseHistory': + case 'getPendingPurchases': + case 'getAvailableItems': + return >[]; + case 'endConnection': + return true; + } + return null; + }); + }); + + tearDown(() { + channel.setMockMethodCallHandler(null); + }); + + testWidgets('shows available purchase cards', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: AvailablePurchasesScreen()), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Active Purchases'), findsOneWidget); + expect(find.text('dev.hyo.martie.premium'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/example/test/purchase_flow_screen_test.dart b/example/test/purchase_flow_screen_test.dart new file mode 100644 index 000000000..20ee22265 --- /dev/null +++ b/example/test/purchase_flow_screen_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase_example/src/screens/purchase_flow_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + + late List log; + + setUp(() { + log = []; + channel.setMockMethodCallHandler((MethodCall call) async { + log.add(call); + switch (call.method) { + case 'initConnection': + return true; + case 'fetchProducts': + final args = call.arguments as Map?; + final type = (args?['type']?.toString() ?? 'inapp') + .replaceAll('-', '') + .toLowerCase(); + if (type == 'inapp') { + return >[ + { + 'platform': 'android', + 'id': 'dev.hyo.martie.10bulbs', + 'productId': 'dev.hyo.martie.10bulbs', + 'title': '10 Bulbs', + 'description': 'Adds 10 bulbs to your account', + 'currency': 'USD', + 'displayPrice': '\$0.99', + 'price': '0.99', + 'type': 'in-app', + 'localizedPrice': '\$0.99', + }, + ]; + } + return []; + case 'getAvailableItems': + case 'getAvailablePurchases': + case 'getPurchaseHistory': + return >[]; + case 'requestPurchase': + return null; + case 'endConnection': + return true; + } + return null; + }); + }); + + tearDown(() { + channel.setMockMethodCallHandler(null); + }); + + testWidgets('loads products and triggers purchase when tapping Buy', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: PurchaseFlowScreen())); + + await tester.pumpAndSettle(); + + expect(log.where((call) => call.method == 'fetchProducts'), isNotEmpty); + expect(find.text('10 Bulbs'), findsOneWidget); + + await tester.tap(find.text('Buy')); + await tester.pump(); + + expect(find.text('Processing...'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/example/test/subscription_flow_screen_test.dart b/example/test/subscription_flow_screen_test.dart new file mode 100644 index 000000000..231e1835b --- /dev/null +++ b/example/test/subscription_flow_screen_test.dart @@ -0,0 +1,85 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase_example/src/screens/subscription_flow_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + + late List log; + + setUp(() { + log = []; + channel.setMockMethodCallHandler((MethodCall call) async { + log.add(call); + switch (call.method) { + case 'initConnection': + return true; + case 'fetchProducts': + final args = call.arguments as Map?; + final type = (args?['type']?.toString() ?? 'subs') + .replaceAll('-', '') + .toLowerCase(); + if (type == 'subs') { + return >[ + { + 'platform': 'ios', + 'id': 'dev.hyo.martie.premium', + 'productId': 'dev.hyo.martie.premium', + 'title': 'Premium Monthly', + 'description': 'Unlock premium access', + 'currency': 'USD', + 'displayPrice': '\$4.99', + 'price': '4.99', + 'type': 'subs', + 'displayNameIOS': 'Premium Monthly', + 'isFamilyShareableIOS': false, + 'jsonRepresentationIOS': '{}', + 'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION', + 'subscriptionInfoIOS': { + 'subscriptionGroupId': 'group1', + 'subscriptionPeriod': { + 'unit': 'MONTH', + 'value': 1, + }, + 'introductoryOffer': null, + 'promotionalOffers': >[], + }, + }, + ]; + } + return []; + case 'getAvailablePurchases': + case 'getAvailableItems': + case 'getPurchaseHistory': + return >[]; + case 'requestPurchase': + return null; + case 'endConnection': + return true; + } + return null; + }); + }); + + tearDown(() { + channel.setMockMethodCallHandler(null); + }); + + testWidgets('renders subscription tiles and triggers requestPurchase', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: SubscriptionFlowScreen()), + ); + + await tester.pumpAndSettle(); + + expect(log.where((call) => call.method == 'fetchProducts'), isNotEmpty); + expect(find.text('No subscriptions available'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/ios/Classes/FlutterInappPurchasePlugin.swift b/ios/Classes/FlutterInappPurchasePlugin.swift index 3e9737c24..95bb59ab4 100644 --- a/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/ios/Classes/FlutterInappPurchasePlugin.swift @@ -4,7 +4,6 @@ import Flutter import OpenIAP @available(iOS 15.0, *) -@MainActor public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private static let TAG = "[FlutterInappPurchase]" private var channel: FlutterMethodChannel? @@ -23,19 +22,23 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { print("\(TAG) Swift register called") - if #available(iOS 15.0, *) { - let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) - let instance = FlutterInappPurchasePlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - instance.channel = channel - // Set up OpenIAP listeners as early as possible (Expo-style) - instance.setupOpenIapListeners() - } else { - print("\(TAG) iOS 15.0+ required for StoreKit 2") - } + let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) + let instance = FlutterInappPurchasePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + instance.channel = channel + // Set up OpenIAP listeners as early as possible (Expo-style) + instance.setupOpenIapListeners() } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + Task { @MainActor [weak self] in + guard let self = self else { return } + self.handleOnMain(call, result: result) + } + } + + @MainActor + private func handleOnMain(_ call: FlutterMethodCall, result: @escaping FlutterResult) { print("\(FlutterInappPurchasePlugin.TAG) Swift handle called with method: '\(call.method)' and arguments: \(String(describing: call.arguments))") switch call.method { @@ -58,7 +61,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let params: [String: Any] = ["skus": skus, "type": "all"] fetchProducts(params: params, result: result) } else { - result(FlutterError(code: OpenIapError.E_DEVELOPER_ERROR, message: "Invalid params for fetchProducts", details: nil)) + result(FlutterError(code: OpenIapError.DeveloperError, message: "Invalid params for fetchProducts", details: nil)) } case "getAvailableItems": @@ -81,7 +84,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } else if let sku = call.arguments as? String { requestPurchase(args: ["sku": sku], result: result) } else { - result(FlutterError(code: OpenIapError.E_DEVELOPER_ERROR, message: "Invalid params for requestPurchase", details: nil)) + result(FlutterError(code: OpenIapError.DeveloperError, message: "Invalid params for requestPurchase", details: nil)) } case "finishTransaction": @@ -100,7 +103,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { guard let id = transactionId else { print("\(FlutterInappPurchasePlugin.TAG) ERROR: No transactionId found in arguments") - result(FlutterError(code: OpenIapError.E_DEVELOPER_ERROR, message: "transactionId required", details: nil)) + result(FlutterError(code: OpenIapError.DeveloperError, message: "transactionId required", details: nil)) return } print("\(FlutterInappPurchasePlugin.TAG) Final transactionId to finish: \(id)") @@ -122,7 +125,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if #available(iOS 16.0, *) { presentCodeRedemptionSheetIOS(result: result) } else { - result(FlutterError(code: OpenIapError.E_FEATURE_NOT_SUPPORTED, message: "Code redemption requires iOS 16.0+", details: nil)) + result(FlutterError(code: OpenIapError.FeatureNotSupported, message: "Code redemption requires iOS 16.0+", details: nil)) } case "getPromotedProductIOS": @@ -136,7 +139,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "validateReceiptIOS": guard let args = call.arguments as? [String: Any], let sku = args["sku"] as? String else { - result(FlutterError(code: OpenIapError.E_DEVELOPER_ERROR, message: "sku required", details: nil)) + result(FlutterError(code: OpenIapError.DeveloperError, message: "sku required", details: nil)) return } validateReceiptIOS(productId: sku, result: result) @@ -158,7 +161,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.E_INIT_CONNECTION + let code = OpenIapError.InitConnection result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -227,7 +230,6 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in guard let self = self else { return } print("\(FlutterInappPurchasePlugin.TAG) 📱 promotedProductListenerIOS fired for: \(productId)") - let payload: [String: Any] = ["productId": productId] // Emit event that Dart expects: name 'iap-promoted-product' with String payload self.channel?.invokeMethod("iap-promoted-product", arguments: productId) } @@ -274,14 +276,14 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let typeStr = (params["type"] as? String) ?? "all" print("\(FlutterInappPurchasePlugin.TAG) fetchProducts called with skus: \(skus), type: \(typeStr)") guard !skus.isEmpty else { - result(FlutterError(code: OpenIapError.E_QUERY_PRODUCT, message: "Empty SKU list provided", details: nil)) + result(FlutterError(code: OpenIapError.QueryProduct, message: "Empty SKU list provided", details: nil)) return } Task { @MainActor in do { let reqType: OpenIapRequestProductType = { switch typeStr.lowercased() { - case "inapp": return .inapp + case "inapp": return .inApp case "subs": return .subs default: return .all } @@ -291,7 +293,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let serialized = OpenIapSerialization.products(products) result(serialized) } catch { - let code = OpenIapError.E_QUERY_PRODUCT + let code = OpenIapError.QueryProduct result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -338,7 +340,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { await MainActor.run { result(sanitized) } } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -349,7 +351,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private func requestPurchase(args: [String: Any], result: @escaping FlutterResult) { let sku = (args["sku"] as? String) ?? (args["productId"] as? String) guard let sku else { - result(FlutterError(code: OpenIapError.E_DEVELOPER_ERROR, message: "sku required", details: nil)) + result(FlutterError(code: OpenIapError.DeveloperError, message: "sku required", details: nil)) return } let autoFinish = (args["andDangerouslyFinishTransactionAutomatically"] as? Bool) ?? false @@ -383,15 +385,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { let errorData: [String: Any] = [ - "code": OpenIapError.E_PURCHASE_ERROR, - "message": defaultMessage(for: OpenIapError.E_PURCHASE_ERROR), + "code": OpenIapError.PurchaseError, + "message": defaultMessage(for: OpenIapError.PurchaseError), "productId": sku ] if let jsonData = try? JSONSerialization.data(withJSONObject: errorData), let jsonString = String(data: jsonData, encoding: .utf8) { channel?.invokeMethod("purchase-error", arguments: jsonString) } - let code = OpenIapError.E_PURCHASE_ERROR + let code = OpenIapError.PurchaseError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -408,7 +410,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -427,7 +429,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -443,7 +445,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.E_ACTIVITY_UNAVAILABLE + let code = OpenIapError.ActivityUnavailable result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -457,7 +459,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -485,7 +487,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -499,7 +501,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(["countryCode": code]) } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -515,7 +517,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(sanitized) } catch { await MainActor.run { - let code = OpenIapError.E_SERVICE_ERROR + let code = OpenIapError.ServiceError result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -529,7 +531,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - result(FlutterError(code: OpenIapError.E_SERVICE_ERROR, message: error.localizedDescription, details: nil)) + result(FlutterError(code: OpenIapError.ServiceError, message: error.localizedDescription, details: nil)) } } } @@ -557,7 +559,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { await MainActor.run { result(payload) } } catch { await MainActor.run { - let code = OpenIapError.E_TRANSACTION_VALIDATION_FAILED + let code = OpenIapError.TransactionValidationFailed result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) } } @@ -584,6 +586,6 @@ public class FlutterInappPurchasePluginLegacy: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result(FlutterError(code: OpenIapError.E_FEATURE_NOT_SUPPORTED, message: "iOS 15.0+ required", details: nil)) + result(FlutterError(code: OpenIapError.FeatureNotSupported, message: "iOS 15.0+ required", details: nil)) } } diff --git a/ios/flutter_inapp_purchase.podspec b/ios/flutter_inapp_purchase.podspec index 819a9da01..ef536b008 100644 --- a/ios/flutter_inapp_purchase.podspec +++ b/ios/flutter_inapp_purchase.podspec @@ -15,8 +15,8 @@ In App Purchase plugin for flutter. This project has been forked by react-native s.source_files = 'Classes/**/*.swift' s.dependency 'Flutter' # Use OpenIAP Apple native module (via CocoaPods) - # Pin exactly 1.1.9 to avoid unexpected updates - s.dependency 'openiap', '1.1.9' + # Pin exactly 1.1.12 to avoid unexpected updates + s.dependency 'openiap', '1.1.12' s.ios.deployment_target = '15.0' s.swift_version = '5.5' diff --git a/lib/builders.dart b/lib/builders.dart index 79f9b2a59..54dac2f6a 100644 --- a/lib/builders.dart +++ b/lib/builders.dart @@ -1,40 +1,50 @@ import 'package:flutter_inapp_purchase/types.dart'; -/// iOS purchase request builder -class RequestPurchaseIOSBuilder { +/// Builder for iOS-specific purchase props +class RequestPurchaseIosBuilder { String sku = ''; - bool? andDangerouslyFinishTransactionAutomaticallyIOS; - String? applicationUsername; + bool? andDangerouslyFinishTransactionAutomatically; String? appAccountToken; - bool? simulatesAskToBuyInSandbox; - String? discountIdentifier; - String? discountTimestamp; - String? discountNonce; - String? discountSignature; - int quantity = 1; - PaymentDiscount? withOffer; - - RequestPurchaseIOSBuilder(); - - RequestPurchaseIOS build() { - return RequestPurchaseIOS( + int? quantity; + DiscountOfferInputIOS? withOffer; + + RequestPurchaseIosBuilder(); + + RequestPurchaseIosProps build() { + return RequestPurchaseIosProps( + sku: sku, + andDangerouslyFinishTransactionAutomatically: + andDangerouslyFinishTransactionAutomatically, + appAccountToken: appAccountToken, + quantity: quantity, + withOffer: withOffer, + ); + } +} + +/// Builder for iOS-specific subscription props +class RequestSubscriptionIosBuilder { + String sku = ''; + bool? andDangerouslyFinishTransactionAutomatically; + String? appAccountToken; + int? quantity; + DiscountOfferInputIOS? withOffer; + + RequestSubscriptionIosBuilder(); + + RequestSubscriptionIosProps build() { + return RequestSubscriptionIosProps( sku: sku, - andDangerouslyFinishTransactionAutomaticallyIOS: - andDangerouslyFinishTransactionAutomaticallyIOS, - applicationUsername: applicationUsername, + andDangerouslyFinishTransactionAutomatically: + andDangerouslyFinishTransactionAutomatically, appAccountToken: appAccountToken, - simulatesAskToBuyInSandbox: simulatesAskToBuyInSandbox, - discountIdentifier: discountIdentifier, - discountTimestamp: discountTimestamp, - discountNonce: discountNonce, - discountSignature: discountSignature, quantity: quantity, withOffer: withOffer, ); } } -/// Android purchase request builder +/// Builder for Android purchase props class RequestPurchaseAndroidBuilder { List skus = const []; String? obfuscatedAccountIdAndroid; @@ -43,8 +53,8 @@ class RequestPurchaseAndroidBuilder { RequestPurchaseAndroidBuilder(); - RequestPurchaseAndroid build() { - return RequestPurchaseAndroid( + RequestPurchaseAndroidProps build() { + return RequestPurchaseAndroidProps( skus: skus, obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid, obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid, @@ -53,10 +63,10 @@ class RequestPurchaseAndroidBuilder { } } -/// Android subscription request builder +/// Builder for Android subscription props class RequestSubscriptionAndroidBuilder { List skus = const []; - List subscriptionOffers = const []; + List subscriptionOffers = const []; String? obfuscatedAccountIdAndroid; String? obfuscatedProfileIdAndroid; String? purchaseTokenAndroid; @@ -65,10 +75,11 @@ class RequestSubscriptionAndroidBuilder { RequestSubscriptionAndroidBuilder(); - RequestSubscriptionAndroid build() { - return RequestSubscriptionAndroid( + RequestSubscriptionAndroidProps build() { + return RequestSubscriptionAndroidProps( skus: skus, - subscriptionOffers: subscriptionOffers, + subscriptionOffers: + subscriptionOffers.isEmpty ? null : subscriptionOffers, obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid, obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid, purchaseTokenAndroid: purchaseTokenAndroid, @@ -78,73 +89,144 @@ class RequestSubscriptionAndroidBuilder { } } -/// Unified request purchase builder +/// Unified purchase parameter builder class RequestPurchaseBuilder { - final ios = RequestPurchaseIOSBuilder(); - final android = RequestPurchaseAndroidBuilder(); - String type = ProductType.inapp; - - RequestPurchaseBuilder(); - - /// Build the final RequestPurchase object - RequestPurchase build() { - return RequestPurchase( - ios: ios.sku.isNotEmpty ? ios.build() : null, - android: android.skus.isNotEmpty ? android.build() : null, - ); + final RequestPurchaseIosBuilder ios = RequestPurchaseIosBuilder(); + final RequestPurchaseAndroidBuilder android = RequestPurchaseAndroidBuilder(); + ProductQueryType _type = ProductQueryType.InApp; + + ProductQueryType get type => _type; + + set type(Object value) { + if (value is ProductQueryType) { + if (value == ProductQueryType.All) { + throw ArgumentError( + 'ProductQueryType.All is not supported in RequestPurchaseBuilder. ' + 'Use RequestSubscriptionBuilder or specify ProductType.InApp/ProductType.Subs explicitly.', + ); + } + _type = value; + return; + } + if (value is ProductType) { + _type = value == ProductType.InApp + ? ProductQueryType.InApp + : ProductQueryType.Subs; + return; + } + if (value is String) { + final normalized = value.toLowerCase(); + if (normalized.contains('sub')) { + _type = ProductQueryType.Subs; + } else { + _type = ProductQueryType.InApp; + } + return; + } + throw ArgumentError('Unsupported type assignment: $value'); } -} - -/// Unified request subscription builder -class RequestSubscriptionBuilder { - final ios = RequestPurchaseIOSBuilder(); - final android = RequestSubscriptionAndroidBuilder(); - String type = ProductType.subs; - RequestSubscriptionBuilder(); + RequestPurchaseBuilder(); - /// Build the final RequestPurchase object for subscriptions - RequestPurchase build() { - return RequestPurchase( - ios: ios.sku.isNotEmpty ? ios.build() : null, - android: android.skus.isNotEmpty ? android.build() : null, + RequestPurchaseProps build() { + final iosProps = ios.sku.isNotEmpty ? ios.build() : null; + final androidProps = android.skus.isNotEmpty ? android.build() : null; + + if (_type == ProductQueryType.InApp) { + final payload = RequestPurchasePropsByPlatforms( + ios: iosProps, + android: androidProps, + ); + return RequestPurchaseProps.inApp(request: payload); + } + + if (_type == ProductQueryType.Subs) { + final iosSub = iosProps == null + ? null + : RequestSubscriptionIosProps( + sku: iosProps.sku, + andDangerouslyFinishTransactionAutomatically: + iosProps.andDangerouslyFinishTransactionAutomatically, + appAccountToken: iosProps.appAccountToken, + quantity: iosProps.quantity, + withOffer: iosProps.withOffer, + ); + + final androidSub = androidProps == null + ? null + : RequestSubscriptionAndroidProps( + skus: androidProps.skus, + isOfferPersonalized: androidProps.isOfferPersonalized, + obfuscatedAccountIdAndroid: + androidProps.obfuscatedAccountIdAndroid, + obfuscatedProfileIdAndroid: + androidProps.obfuscatedProfileIdAndroid, + purchaseTokenAndroid: null, + replacementModeAndroid: null, + subscriptionOffers: null, + ); + + final subscriptionPayload = RequestSubscriptionPropsByPlatforms( + ios: iosSub, + android: androidSub, + ); + return RequestPurchaseProps.subs(request: subscriptionPayload); + } + + throw ArgumentError( + 'ProductQueryType.All is not supported in RequestPurchaseBuilder. ' + 'Use RequestSubscriptionBuilder or specify ProductType.InApp/ProductType.Subs explicitly.', ); } } -// Type definitions for builder functions -typedef IosBuilder = void Function(RequestPurchaseIOSBuilder builder); -typedef AndroidBuilder = void Function(RequestPurchaseAndroidBuilder builder); +typedef IosPurchaseBuilder = void Function(RequestPurchaseIosBuilder builder); +typedef AndroidPurchaseBuilder = void Function( + RequestPurchaseAndroidBuilder builder); +typedef IosSubscriptionBuilder = void Function( + RequestSubscriptionIosBuilder builder); typedef AndroidSubscriptionBuilder = void Function( RequestSubscriptionAndroidBuilder builder); typedef RequestBuilder = void Function(RequestPurchaseBuilder builder); -typedef SubscriptionBuilder = void Function(RequestSubscriptionBuilder builder); -/// Extensions to enable cascade notation extension RequestPurchaseBuilderExtension on RequestPurchaseBuilder { - /// Configure iOS-specific settings - RequestPurchaseBuilder withIOS(IosBuilder configure) { + RequestPurchaseBuilder withIOS(IosPurchaseBuilder configure) { configure(ios); return this; } - /// Configure Android-specific settings - RequestPurchaseBuilder withAndroid(AndroidBuilder configure) { + RequestPurchaseBuilder withAndroid(AndroidPurchaseBuilder configure) { configure(android); return this; } } -extension RequestSubscriptionBuilderExtension on RequestSubscriptionBuilder { - /// Configure iOS-specific settings - RequestSubscriptionBuilder withIOS(IosBuilder configure) { +class RequestSubscriptionBuilder { + RequestSubscriptionBuilder(); + + final RequestSubscriptionIosBuilder ios = RequestSubscriptionIosBuilder(); + final RequestSubscriptionAndroidBuilder android = + RequestSubscriptionAndroidBuilder(); + + RequestSubscriptionBuilder withIOS(IosSubscriptionBuilder configure) { configure(ios); return this; } - /// Configure Android-specific subscription settings RequestSubscriptionBuilder withAndroid(AndroidSubscriptionBuilder configure) { configure(android); return this; } + + RequestPurchaseProps build() { + final iosProps = ios.sku.isNotEmpty ? ios.build() : null; + final androidProps = android.skus.isNotEmpty ? android.build() : null; + + return RequestPurchaseProps.subs( + request: RequestSubscriptionPropsByPlatforms( + ios: iosProps, + android: androidProps, + ), + ); + } } diff --git a/lib/enums.dart b/lib/enums.dart index 6e54f26e6..c28daf553 100644 --- a/lib/enums.dart +++ b/lib/enums.dart @@ -1,5 +1,5 @@ // Enums for flutter_inapp_purchase package -library; +// ignore_for_file: constant_identifier_names /// Store types enum Store { none, playStore, amazon, appStore } @@ -95,6 +95,7 @@ enum OfferType { introductory, promotional, code, winBack } enum BillingClientState { disconnected, connecting, connected, closed } /// Replacement mode (Android) +@Deprecated('Use AndroidReplacementMode') enum ReplacementMode { withTimeProration, chargeProratedPrice, @@ -104,6 +105,7 @@ enum ReplacementMode { } /// Replace mode (Android) +@Deprecated('Use AndroidReplacementMode') enum ReplaceMode { withTimeProration, chargeProratedPrice, @@ -117,18 +119,32 @@ enum TypeInApp { inapp, subs } /// Android purchase states from Google Play Billing enum AndroidPurchaseState { - unspecified(0), // UNSPECIFIED_STATE - purchased(1), // PURCHASED - pending(2); // PENDING - - final int value; - const AndroidPurchaseState(this.value); - - static AndroidPurchaseState fromValue(int value) { - return AndroidPurchaseState.values.firstWhere( - (state) => state.value == value, - orElse: () => AndroidPurchaseState.unspecified, - ); + Unknown, // UNSPECIFIED_STATE + Purchased, // PURCHASED + Pending, // PENDING +} + +AndroidPurchaseState androidPurchaseStateFromValue(int value) { + switch (value) { + case 1: + return AndroidPurchaseState.Purchased; + case 2: + return AndroidPurchaseState.Pending; + default: + return AndroidPurchaseState.Unknown; + } +} + +extension AndroidPurchaseStateValue on AndroidPurchaseState { + int get value { + switch (this) { + case AndroidPurchaseState.Unknown: + return 0; + case AndroidPurchaseState.Purchased: + return 1; + case AndroidPurchaseState.Pending: + return 2; + } } } @@ -177,13 +193,29 @@ enum PurchaseState { pending, purchased, unspecified } /// } /// ``` enum AndroidReplacementMode { - unknownReplacementMode(0), - withTimeProration(1), - chargeProratedPrice(2), - withoutProration(3), - deferred(4), - chargeFullPrice(5); - - final int value; - const AndroidReplacementMode(this.value); + unknownReplacementMode, + withTimeProration, + chargeProratedPrice, + withoutProration, + deferred, + chargeFullPrice, +} + +extension AndroidReplacementModeValue on AndroidReplacementMode { + int get value { + switch (this) { + case AndroidReplacementMode.unknownReplacementMode: + return 0; + case AndroidReplacementMode.withTimeProration: + return 1; + case AndroidReplacementMode.chargeProratedPrice: + return 2; + case AndroidReplacementMode.withoutProration: + return 3; + case AndroidReplacementMode.deferred: + return 4; + case AndroidReplacementMode.chargeFullPrice: + return 5; + } + } } diff --git a/lib/errors.dart b/lib/errors.dart index d0bdc59c6..a6e0e9637 100644 --- a/lib/errors.dart +++ b/lib/errors.dart @@ -2,14 +2,14 @@ library errors; import 'dart:io'; -import 'enums.dart'; +import 'types.dart'; /// Get current platform IapPlatform getCurrentPlatform() { if (Platform.isIOS) { - return IapPlatform.ios; + return IapPlatform.IOS; } else if (Platform.isAndroid) { - return IapPlatform.android; + return IapPlatform.Android; } throw UnsupportedError('Platform not supported'); } @@ -19,15 +19,12 @@ class ErrorCodeMapping { static const Map ios = { // OpenIAP standard error codes ErrorCode.Unknown: 0, - ErrorCode.UserCancelled: 2, // SKErrorPaymentCancelled - ErrorCode.NetworkError: 1, // SKErrorClientInvalid + ErrorCode.UserCancelled: 2, + ErrorCode.NetworkError: 1, ErrorCode.ItemUnavailable: 3, ErrorCode.ServiceError: 4, ErrorCode.ReceiptFailed: 5, ErrorCode.AlreadyOwned: 6, - ErrorCode.ProductNotAvailable: 7, - ErrorCode.ProductAlreadyOwned: 8, - ErrorCode.UserError: 9, ErrorCode.RemoteError: 10, ErrorCode.ReceiptFinished: 11, ErrorCode.Pending: 12, @@ -49,13 +46,10 @@ class ErrorCodeMapping { }; static const Map android = { - // OpenIAP standard error codes ErrorCode.Unknown: 'E_UNKNOWN', ErrorCode.UserCancelled: 'E_USER_CANCELLED', ErrorCode.UserError: 'E_USER_ERROR', ErrorCode.ItemUnavailable: 'E_ITEM_UNAVAILABLE', - ErrorCode.ProductNotAvailable: 'E_PRODUCT_NOT_AVAILABLE', - ErrorCode.ProductAlreadyOwned: 'E_PRODUCT_ALREADY_OWNED', ErrorCode.AlreadyOwned: 'E_ALREADY_OWNED', ErrorCode.NetworkError: 'E_NETWORK_ERROR', ErrorCode.ServiceError: 'E_SERVICE_ERROR', @@ -65,7 +59,6 @@ class ErrorCodeMapping { ErrorCode.Pending: 'E_PENDING', ErrorCode.NotEnded: 'E_NOT_ENDED', ErrorCode.DeveloperError: 'E_DEVELOPER_ERROR', - // Legacy codes for compatibility ErrorCode.ReceiptFinishedFailed: 'E_RECEIPT_FINISHED_FAILED', ErrorCode.NotPrepared: 'E_NOT_PREPARED', ErrorCode.BillingResponseJsonParseError: @@ -79,6 +72,12 @@ class ErrorCodeMapping { ErrorCode.ActivityUnavailable: 'E_ACTIVITY_UNAVAILABLE', ErrorCode.AlreadyPrepared: 'E_ALREADY_PREPARED', ErrorCode.ConnectionClosed: 'E_CONNECTION_CLOSED', + ErrorCode.BillingUnavailable: 'E_BILLING_UNAVAILABLE', + ErrorCode.SkuNotFound: 'E_SKU_NOT_FOUND', + ErrorCode.SkuOfferMismatch: 'E_SKU_OFFER_MISMATCH', + ErrorCode.ItemNotOwned: 'E_ITEM_NOT_OWNED', + ErrorCode.FeatureNotSupported: 'E_FEATURE_NOT_SUPPORTED', + ErrorCode.EmptySkuList: 'E_EMPTY_SKU_LIST', }; } @@ -91,11 +90,11 @@ ErrorCode _normalizeToErrorCode(dynamic error) { if (code is String) { // OpenIAP uses normalized string codes across platforms; reuse mapping // used for Android since codes are identical. - return ErrorCodeUtils.fromPlatformCode(code, IapPlatform.android); + return ErrorCodeUtils.fromPlatformCode(code, IapPlatform.Android); } if (code is int) { // Legacy iOS numeric codes - return ErrorCodeUtils.fromPlatformCode(code, IapPlatform.ios); + return ErrorCodeUtils.fromPlatformCode(code, IapPlatform.IOS); } return ErrorCode.Unknown; } @@ -109,10 +108,9 @@ String getUserFriendlyErrorMessage(dynamic error) { case ErrorCode.NetworkError: return 'Network connection error. Please check your internet connection and try again.'; case ErrorCode.ItemUnavailable: - case ErrorCode.ProductNotAvailable: + case ErrorCode.SkuNotFound: return 'This item is not available for purchase'; case ErrorCode.AlreadyOwned: - case ErrorCode.ProductAlreadyOwned: return 'You already own this item'; case ErrorCode.DeferredPayment: return 'Payment is pending approval'; @@ -258,7 +256,7 @@ class ErrorCodeUtils { /// Maps an ErrorCode enum to platform-specific code static dynamic toPlatformCode(ErrorCode errorCode, IapPlatform platform) { - if (platform == IapPlatform.ios) { + if (platform == IapPlatform.IOS) { return ErrorCodeMapping.ios[errorCode] ?? 0; } else { return ErrorCodeMapping.android[errorCode] ?? 'E_UNKNOWN'; @@ -267,7 +265,7 @@ class ErrorCodeUtils { /// Checks if an error code is valid for the specified platform static bool isValidForPlatform(ErrorCode errorCode, IapPlatform platform) { - if (platform == IapPlatform.ios) { + if (platform == IapPlatform.IOS) { return ErrorCodeMapping.ios.containsKey(errorCode); } else { return ErrorCodeMapping.android.containsKey(errorCode); diff --git a/lib/events.dart b/lib/events.dart index f34d6cbaa..147356ab9 100644 --- a/lib/events.dart +++ b/lib/events.dart @@ -1,30 +1,20 @@ -/// Event types for flutter_inapp_purchase (OpenIAP compliant) -library; +// Event types for flutter_inapp_purchase (OpenIAP compliant) -import 'types.dart'; +import 'types.dart' as types; -/// Event types enum (OpenIAP compliant) -enum IapEvent { - /// Purchase successful or updated - purchaseUpdated, - - /// Purchase failed or cancelled - purchaseError, - - /// Promoted product clicked (iOS) - promotedProductIos, -} +/// Alias to the generated event enum so callers keep using the familiar name. +typedef IapEvent = types.IapEvent; /// Purchase updated event payload class PurchaseUpdatedEvent { - final Purchase purchase; + final types.Purchase purchase; PurchaseUpdatedEvent({required this.purchase}); } /// Purchase error event payload class PurchaseErrorEvent { - final PurchaseError error; + final types.PurchaseError error; PurchaseErrorEvent({required this.error}); } diff --git a/lib/flutter_inapp_purchase.dart b/lib/flutter_inapp_purchase.dart index ed2003bc2..a33545663 100644 --- a/lib/flutter_inapp_purchase.dart +++ b/lib/flutter_inapp_purchase.dart @@ -15,17 +15,341 @@ import 'builders.dart'; import 'utils.dart'; export 'types.dart'; -export 'enums.dart'; -export 'errors.dart'; -export 'events.dart'; export 'builders.dart'; export 'utils.dart'; +export 'enums.dart' hide IapPlatform, ErrorCode, PurchaseState; +export 'errors.dart' show getCurrentPlatform; + +// --------------------------------------------------------------------------- +// Legacy compatibility helpers (kept for existing example/tests) +// --------------------------------------------------------------------------- + +extension PurchaseLegacyCompat on iap_types.Purchase { + String? get transactionId => id.isEmpty ? null : id; + + int? get purchaseStateAndroid { + if (this is iap_types.PurchaseAndroid) { + final state = (this as iap_types.PurchaseAndroid).purchaseState; + switch (state) { + case iap_types.PurchaseState.Purchased: + return AndroidPurchaseState.Purchased.value; + case iap_types.PurchaseState.Pending: + return AndroidPurchaseState.Pending.value; + default: + return AndroidPurchaseState.Unknown.value; + } + } + return null; + } + + TransactionState? get transactionStateIOS { + switch (purchaseState) { + case iap_types.PurchaseState.Purchased: + return TransactionState.purchased; + case iap_types.PurchaseState.Pending: + return TransactionState.purchasing; + case iap_types.PurchaseState.Failed: + return TransactionState.failed; + case iap_types.PurchaseState.Deferred: + return TransactionState.deferred; + case iap_types.PurchaseState.Restored: + return TransactionState.restored; + case iap_types.PurchaseState.Unknown: + return TransactionState.purchasing; + } + } + + bool? get isAcknowledgedAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).isAcknowledgedAndroid + : null; + + bool? get autoRenewingAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).autoRenewingAndroid + : null; + + String? get dataAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).dataAndroid + : null; + + String? get signatureAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).signatureAndroid + : null; + + String? get packageNameAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).packageNameAndroid + : null; + + String? get obfuscatedAccountIdAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).obfuscatedAccountIdAndroid + : null; -// Enums moved to enums.dart + String? get obfuscatedProfileIdAndroid => this is iap_types.PurchaseAndroid + ? (this as iap_types.PurchaseAndroid).obfuscatedProfileIdAndroid + : null; -// MARK: - Classes from modules.dart + String? get orderIdAndroid => null; -// MARK: - Main FlutterInappPurchase class + int? get quantityIOS => this is iap_types.PurchaseIOS + ? (this as iap_types.PurchaseIOS).quantityIOS + : null; + + String? get originalTransactionIdentifierIOS => this is iap_types.PurchaseIOS + ? (this as iap_types.PurchaseIOS).originalTransactionIdentifierIOS + : null; + + double? get originalTransactionDateIOS => this is iap_types.PurchaseIOS + ? (this as iap_types.PurchaseIOS).originalTransactionDateIOS + : null; + + String? get environmentIOS => this is iap_types.PurchaseIOS + ? (this as iap_types.PurchaseIOS).environmentIOS + : null; + + String? get transactionReceipt => purchaseToken; +} + +extension ProductCommonLegacyCompat on iap_types.ProductCommon { + String get localizedPrice => displayPrice; + + iap_types.IapPlatform get platformEnum => platform; + + List? get discountsIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS).discountsIOS; + } + return null; + } + + String? get signatureAndroid => null; + + String? get iconUrl => null; +} + +extension ProductSubscriptionLegacyCompat on iap_types.ProductSubscription { + String? get subscriptionPeriodAndroid => null; + + String? get subscriptionPeriodUnitIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS) + .subscriptionPeriodUnitIOS + ?.toJson(); + } + return null; + } + + String? get subscriptionPeriodNumberIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS) + .subscriptionPeriodNumberIOS; + } + return null; + } + + String? get introductoryPriceNumberOfPeriodsIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS) + .introductoryPriceNumberOfPeriodsIOS; + } + return null; + } + + String? get introductoryPriceSubscriptionPeriodIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS) + .introductoryPriceSubscriptionPeriodIOS + ?.toJson(); + } + return null; + } + + List? get discountsIOS { + if (this is iap_types.ProductSubscriptionIOS) { + return (this as iap_types.ProductSubscriptionIOS).discountsIOS; + } + return null; + } + + List? get subscriptionOffersAndroid { + if (this is iap_types.ProductSubscriptionAndroid) { + final details = (this as iap_types.ProductSubscriptionAndroid) + .subscriptionOfferDetailsAndroid; + return details + .map((offer) => iap_types.AndroidSubscriptionOfferInput( + offerToken: offer.offerToken, + sku: offer.basePlanId, + )) + .toList(); + } + return null; + } + + iap_types.SubscriptionInfoIOS? get subscriptionInfoIOS => + this is iap_types.ProductSubscriptionIOS + ? (this as iap_types.ProductSubscriptionIOS).subscriptionInfoIOS + : null; + + dynamic get subscription => subscriptionInfoIOS; + + List? + get subscriptionOfferDetailsAndroid => subscriptionOffersAndroid; +} + +extension ProductCommonMapCompat on iap_types.ProductCommon { + Map toLegacyJson() { + if (this is iap_types.ProductAndroid) { + return (this as iap_types.ProductAndroid).toJson(); + } + if (this is iap_types.ProductIOS) { + return (this as iap_types.ProductIOS).toJson(); + } + return {}; + } +} + +extension PurchaseErrorLegacyCompat on iap_types.PurchaseError { + iap_types.IapPlatform? get platform => null; + int? get responseCode => null; +} + +typedef SubscriptionOfferAndroid = iap_types.AndroidSubscriptionOfferInput; + +/// Legacy purchase request container (pre-generated API). +class RequestPurchase { + RequestPurchase({ + this.android, + this.ios, + iap_types.ProductType? type, + }) : type = type ?? + (android is RequestSubscriptionAndroid + ? iap_types.ProductType.Subs + : iap_types.ProductType.InApp); + + final RequestPurchaseAndroid? android; + final RequestPurchaseIOS? ios; + final iap_types.ProductType type; + + iap_types.RequestPurchaseProps toProps() { + if (type == iap_types.ProductType.InApp) { + return iap_types.RequestPurchaseProps.inApp( + request: iap_types.RequestPurchasePropsByPlatforms( + android: android?.toInAppProps(), + ios: ios?.toInAppProps(), + ), + ); + } + + return iap_types.RequestPurchaseProps.subs( + request: iap_types.RequestSubscriptionPropsByPlatforms( + android: (android is RequestSubscriptionAndroid) + ? (android as RequestSubscriptionAndroid).toSubscriptionProps() + : null, + ios: ios?.toSubscriptionProps(), + ), + ); + } +} + +class RequestPurchaseAndroid { + RequestPurchaseAndroid({ + required this.skus, + this.obfuscatedAccountIdAndroid, + this.obfuscatedProfileIdAndroid, + this.isOfferPersonalized, + }); + + List skus; + String? obfuscatedAccountIdAndroid; + String? obfuscatedProfileIdAndroid; + bool? isOfferPersonalized; + + iap_types.RequestPurchaseAndroidProps toInAppProps() { + return iap_types.RequestPurchaseAndroidProps( + skus: skus, + obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid, + obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid, + isOfferPersonalized: isOfferPersonalized, + ); + } + + iap_types.RequestSubscriptionAndroidProps toSubscriptionProps() { + return iap_types.RequestSubscriptionAndroidProps( + skus: skus, + isOfferPersonalized: isOfferPersonalized, + obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid, + obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid, + purchaseTokenAndroid: null, + replacementModeAndroid: null, + subscriptionOffers: null, + ); + } +} + +class RequestSubscriptionAndroid extends RequestPurchaseAndroid { + RequestSubscriptionAndroid({ + required super.skus, + this.purchaseTokenAndroid, + this.replacementModeAndroid, + this.subscriptionOffers, + super.obfuscatedAccountIdAndroid, + super.obfuscatedProfileIdAndroid, + super.isOfferPersonalized, + }); + + final String? purchaseTokenAndroid; + final int? replacementModeAndroid; + final List? subscriptionOffers; + + @override + iap_types.RequestSubscriptionAndroidProps toSubscriptionProps() { + return iap_types.RequestSubscriptionAndroidProps( + skus: skus, + isOfferPersonalized: isOfferPersonalized, + obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid, + obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid, + purchaseTokenAndroid: purchaseTokenAndroid, + replacementModeAndroid: replacementModeAndroid, + subscriptionOffers: subscriptionOffers, + ); + } +} + +class RequestPurchaseIOS { + RequestPurchaseIOS({ + required this.sku, + this.quantity, + this.appAccountToken, + this.withOffer, + this.andDangerouslyFinishTransactionAutomatically, + }); + + final String sku; + final int? quantity; + final String? appAccountToken; + final iap_types.DiscountOfferInputIOS? withOffer; + final bool? andDangerouslyFinishTransactionAutomatically; + + iap_types.RequestPurchaseIosProps toInAppProps() { + return iap_types.RequestPurchaseIosProps( + sku: sku, + quantity: quantity, + appAccountToken: appAccountToken, + andDangerouslyFinishTransactionAutomatically: + andDangerouslyFinishTransactionAutomatically, + withOffer: withOffer, + ); + } + + iap_types.RequestSubscriptionIosProps toSubscriptionProps() { + return iap_types.RequestSubscriptionIosProps( + sku: sku, + quantity: quantity, + appAccountToken: appAccountToken, + andDangerouslyFinishTransactionAutomatically: + andDangerouslyFinishTransactionAutomatically, + withOffer: withOffer, + ); + } +} class FlutterInappPurchase with FlutterInappPurchaseIOS, FlutterInappPurchaseAndroid { @@ -45,17 +369,15 @@ class FlutterInappPurchase return _purchaseController!.stream; } - StreamController? _purchaseErrorController; - Stream get purchaseError { - _purchaseErrorController ??= - StreamController.broadcast(); + StreamController? _purchaseErrorController; + Stream get purchaseError { + _purchaseErrorController ??= StreamController.broadcast(); return _purchaseErrorController!.stream; } - StreamController? _connectionController; - Stream get connectionUpdated { - _connectionController ??= - StreamController.broadcast(); + StreamController? _connectionController; + Stream get connectionUpdated { + _connectionController ??= StreamController.broadcast(); return _connectionController!.stream; } @@ -125,32 +447,21 @@ class FlutterInappPurchase /// Initialize connection (flutter IAP compatible) Future initConnection() async { if (_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.AlreadyInitialized, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.AlreadyPrepared, message: 'IAP connection already initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } try { - // For flutter IAP compatibility, call initConnection directly await _setPurchaseListener(); - if (_platform.isIOS) { - await _channel.invokeMethod('initConnection'); - } else if (_platform.isAndroid) { - await _channel.invokeMethod('initConnection'); - } + await _channel.invokeMethod('initConnection'); _isInitialized = true; return true; - } catch (e) { + } catch (error) { throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, - message: 'Failed to initialize IAP connection: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, + code: iap_types.ErrorCode.NotPrepared, + message: 'Failed to initialize IAP connection: ${error.toString()}', ); } } @@ -171,125 +482,167 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to end IAP connection: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } - // requestProducts removed in 6.6.0 - /// Request purchase (flutter IAP compatible) Future requestPurchase({ - required iap_types.RequestPurchase request, - required String type, + iap_types.RequestPurchaseProps? props, + @Deprecated('Use props parameter') RequestPurchase? request, + @Deprecated('Use props parameter') iap_types.ProductType? type, }) async { if (!_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.NotPrepared, message: 'IAP connection not initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } + final effectiveProps = props ?? request?.toProps(); + if (effectiveProps == null) { + throw ArgumentError('RequestPurchaseProps are required.'); + } + + if (type != null) { + final expected = type == iap_types.ProductType.InApp + ? iap_types.ProductQueryType.InApp + : iap_types.ProductQueryType.Subs; + if (effectiveProps.type != expected) { + debugPrint( + '[flutter_inapp_purchase] Warning: ignoring deprecated type argument in requestPurchase. ' + 'props.type=${effectiveProps.type}, provided type=$type', + ); + } + } + + final requestVariant = effectiveProps.request; + final isSubscription = + requestVariant is iap_types.RequestPurchasePropsRequestSubscription; + try { if (_platform.isIOS) { - final iosRequest = request.ios; - if (iosRequest == null) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.DeveloperError, - message: 'iOS request parameters are required for iOS platform', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, - ); - } + if (isSubscription) { + final iosRequest = (requestVariant).value.ios; + if (iosRequest == null) { + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: 'Missing iOS subscription parameters', + ); + } - if (iosRequest.withOffer != null) { - await _channel - .invokeMethod('requestProductWithOfferIOS', { - 'sku': iosRequest.sku, - 'forUser': iosRequest.appAccountToken ?? '', - 'withOffer': iosRequest.withOffer!.toJson(), - }); - } else if (iosRequest.quantity != null && iosRequest.quantity! > 1) { - await _channel.invokeMethod( - 'requestProductWithQuantityIOS', - { + if (iosRequest.withOffer != null) { + await _channel.invokeMethod('requestProductWithOfferIOS', { 'sku': iosRequest.sku, - 'quantity': iosRequest.quantity!.toString(), - }, - ); - } else { - if (type == iap_types.ProductType.subs) { + 'forUser': iosRequest.appAccountToken ?? '', + 'withOffer': iosRequest.withOffer!.toJson(), + }); + } else if (iosRequest.quantity != null && iosRequest.quantity! > 1) { + await _channel.invokeMethod( + 'requestProductWithQuantityIOS', + { + 'sku': iosRequest.sku, + 'quantity': iosRequest.quantity!.toString(), + }, + ); + } else { // ignore: deprecated_member_use_from_same_package await requestSubscription(iosRequest.sku); + } + } else { + final iosRequest = + (requestVariant as iap_types.RequestPurchasePropsRequestPurchase) + .value + .ios; + if (iosRequest == null) { + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: 'Missing iOS purchase parameters', + ); + } + + if (iosRequest.withOffer != null) { + await _channel.invokeMethod('requestProductWithOfferIOS', { + 'sku': iosRequest.sku, + 'forUser': iosRequest.appAccountToken ?? '', + 'withOffer': iosRequest.withOffer!.toJson(), + }); + } else if (iosRequest.quantity != null && iosRequest.quantity! > 1) { + await _channel.invokeMethod( + 'requestProductWithQuantityIOS', + { + 'sku': iosRequest.sku, + 'quantity': iosRequest.quantity!.toString(), + }, + ); } else { - await _channel.invokeMethod('requestPurchase', { + await _channel.invokeMethod('requestPurchase', { 'sku': iosRequest.sku, 'appAccountToken': iosRequest.appAccountToken, }); } } } else if (_platform.isAndroid) { - final androidRequest = request.android; - if (androidRequest == null) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.DeveloperError, - message: - 'Android request parameters are required for Android platform', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, - ); - } - - final sku = - androidRequest.skus.isNotEmpty ? androidRequest.skus.first : ''; - if (type == iap_types.ProductType.subs) { - // Check if this is a RequestSubscriptionAndroid - if (androidRequest is iap_types.RequestSubscriptionAndroid) { - // Validate proration mode requirements before calling requestSubscription - if (androidRequest.replacementModeAndroid != null && - androidRequest.replacementModeAndroid != -1 && - (androidRequest.purchaseTokenAndroid == null || - androidRequest.purchaseTokenAndroid!.isEmpty)) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.DeveloperError, - message: - 'purchaseTokenAndroid is required when using replacementModeAndroid (proration mode). ' - 'You need the purchase token from the existing subscription to upgrade/downgrade.', - platform: iap_types.IapPlatform.android, - ); - } - - // ignore: deprecated_member_use_from_same_package - await requestSubscription( - sku, - obfuscatedAccountIdAndroid: - androidRequest.obfuscatedAccountIdAndroid, - obfuscatedProfileIdAndroid: - androidRequest.obfuscatedProfileIdAndroid, - purchaseTokenAndroid: androidRequest.purchaseTokenAndroid, - replacementModeAndroid: androidRequest.replacementModeAndroid, + if (isSubscription) { + final androidRequest = (requestVariant).value.android; + if (androidRequest == null) { + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: 'Missing Android subscription parameters', ); - } else { - // ignore: deprecated_member_use_from_same_package - await requestSubscription( - sku, - obfuscatedAccountIdAndroid: - androidRequest.obfuscatedAccountIdAndroid, - obfuscatedProfileIdAndroid: - androidRequest.obfuscatedProfileIdAndroid, + } + + final sku = + androidRequest.skus.isNotEmpty ? androidRequest.skus.first : ''; + + if (androidRequest.replacementModeAndroid != null && + androidRequest.replacementModeAndroid != -1 && + (androidRequest.purchaseTokenAndroid == null || + androidRequest.purchaseTokenAndroid!.isEmpty)) { + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: + 'purchaseTokenAndroid is required when using replacementModeAndroid (proration mode). ' + 'You need the purchase token from the existing subscription to upgrade/downgrade.', ); } + + await _channel.invokeMethod('requestPurchase', { + 'type': TypeInApp.subs.name, + 'skus': androidRequest.skus, + 'productId': sku, + if (androidRequest.obfuscatedAccountIdAndroid != null) + 'obfuscatedAccountId': androidRequest.obfuscatedAccountIdAndroid, + if (androidRequest.obfuscatedProfileIdAndroid != null) + 'obfuscatedProfileId': androidRequest.obfuscatedProfileIdAndroid, + if (androidRequest.isOfferPersonalized != null) + 'isOfferPersonalized': androidRequest.isOfferPersonalized, + if (androidRequest.purchaseTokenAndroid != null) + 'purchaseToken': androidRequest.purchaseTokenAndroid, + if (androidRequest.replacementModeAndroid != null) + 'replacementMode': androidRequest.replacementModeAndroid, + if (androidRequest.subscriptionOffers != null && + androidRequest.subscriptionOffers!.isNotEmpty) + 'subscriptionOffers': androidRequest.subscriptionOffers! + .map((offer) => offer.toJson()) + .toList(), + }); } else { - // Use OpenIAP-compatible requestPurchase API on Android - // Backward-compat: include productId to satisfy older handlers/tests - await _channel.invokeMethod('requestPurchase', { + final androidRequest = + (requestVariant as iap_types.RequestPurchasePropsRequestPurchase) + .value + .android; + if (androidRequest == null) { + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: 'Missing Android purchase parameters', + ); + } + + final sku = + androidRequest.skus.isNotEmpty ? androidRequest.skus.first : ''; + + await _channel.invokeMethod('requestPurchase', { 'type': TypeInApp.inapp.name, 'skus': [sku], 'productId': sku, @@ -302,25 +655,17 @@ class FlutterInappPurchase }); } } - } catch (e) { - if (e is iap_types.PurchaseError) { + } catch (error) { + if (error is iap_types.PurchaseError) { rethrow; } throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, - message: 'Failed to request purchase: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, + message: 'Failed to request purchase: ${error.toString()}', ); } } - /// Request purchase with automatic platform detection - /// This method simplifies the purchase request by automatically detecting the platform - /// and using the appropriate parameters from the RequestPurchase object - // requestPurchaseAuto removed in 6.6.0 - /// DSL-like request purchase method with builder pattern /// Provides a more intuitive and type-safe way to build purchase requests /// @@ -328,7 +673,7 @@ class FlutterInappPurchase /// ```dart /// await iap.requestPurchaseWithBuilder( /// build: (r) => r - /// ..type = ProductType.inapp + /// ..type = ProductType.InApp /// ..withIOS((i) => i /// ..sku = 'product_id' /// ..quantity = 1) @@ -341,8 +686,9 @@ class FlutterInappPurchase }) async { final builder = RequestPurchaseBuilder(); build(builder); - final request = builder.build(); - await requestPurchase(request: request, type: builder.type); + final props = builder.build(); + + await requestPurchase(props: props); } /// DSL-like request subscription method with builder pattern @@ -358,12 +704,9 @@ class FlutterInappPurchase iap_types.PurchaseOptions? options, ]) async { if (!_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.NotPrepared, message: 'IAP connection not initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } @@ -376,20 +719,18 @@ class FlutterInappPurchase return items .where((p) => p.productId.isNotEmpty && - ((p.purchaseToken != null && p.purchaseToken!.isNotEmpty) || - (p.transactionId != null && p.transactionId!.isNotEmpty))) + (p.purchaseToken != null && p.purchaseToken!.isNotEmpty)) .toList(); } else if (_platform.isIOS) { // On iOS, pass both iOS-specific options to native method - final args = options?.toMap() ?? {}; + final args = options?.toJson() ?? {}; dynamic result = await _channel.invokeMethod('getAvailableItems', args); final items = extractPurchases(json.encode(result)) ?? []; return items .where((p) => p.productId.isNotEmpty && - ((p.purchaseToken != null && p.purchaseToken!.isNotEmpty) || - (p.transactionId != null && p.transactionId!.isNotEmpty))) + (p.purchaseToken != null && p.purchaseToken!.isNotEmpty)) .toList(); } return []; @@ -397,70 +738,6 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to get available purchases: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, - ); - } - } - - /// Get complete purchase histories - /// Returns all purchases including consumed and finished ones - /// - /// @deprecated - Use getAvailablePurchases with PurchaseOptions instead - /// To get expired subscriptions on iOS, use: - /// ```dart - /// getAvailablePurchases(PurchaseOptions(onlyIncludeActiveItemsIOS: false)) - /// ``` - @Deprecated( - 'Use getAvailablePurchases with PurchaseOptions instead. ' - 'Will be removed in 6.6.0', - ) - Future> getPurchaseHistories() async { - if (!_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, - message: 'IAP connection not initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, - ); - } - - try { - final List history = []; - - if (_platform.isAndroid) { - // Get purchase history for consumables - final dynamic inappHistory = await _channel.invokeMethod( - 'getPurchaseHistoryByType', - {'type': TypeInApp.inapp.name}, - ); - final inappItems = extractPurchases(inappHistory) ?? []; - history.addAll(inappItems); - - // Get purchase history for subscriptions - final dynamic subsHistory = await _channel.invokeMethod( - 'getPurchaseHistoryByType', - {'type': TypeInApp.subs.name}, - ); - final subsItems = extractPurchases(subsHistory) ?? []; - history.addAll(subsItems); - } else if (_platform.isIOS) { - // On iOS, use getPurchaseHistoriesIOS to get ALL transactions including expired ones - dynamic result = await _channel.invokeMethod('getPurchaseHistoriesIOS'); - final items = extractPurchases(json.encode(result)) ?? []; - history.addAll(items); - } - - return history; - } catch (e) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.ServiceError, - message: 'Failed to get purchase history: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } @@ -468,12 +745,9 @@ class FlutterInappPurchase /// iOS specific: Get storefront Future getStorefrontIOS() async { if (!_platform.isIOS) { - throw iap_types.PurchaseError( + throw const iap_types.PurchaseError( code: iap_types.ErrorCode.IapNotAvailable, message: 'Storefront is only available on iOS', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } @@ -484,12 +758,9 @@ class FlutterInappPurchase if (result != null && result['countryCode'] != null) { return result['countryCode'] as String; } - throw iap_types.PurchaseError( + throw const iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to get storefront country code', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } catch (e) { if (e is iap_types.PurchaseError) { @@ -498,13 +769,35 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to get storefront: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } + String _resolveProductType(Object type) { + if (type is String) { + return type; + } + if (type is TypeInApp) { + return type.name; + } + if (type is iap_types.ProductType) { + return type == iap_types.ProductType.InApp + ? TypeInApp.inapp.name + : TypeInApp.subs.name; + } + if (type is iap_types.ProductQueryType) { + switch (type) { + case iap_types.ProductQueryType.InApp: + return TypeInApp.inapp.name; + case iap_types.ProductQueryType.Subs: + return TypeInApp.subs.name; + case iap_types.ProductQueryType.All: + return 'all'; + } + } + return TypeInApp.inapp.name; + } + /// iOS specific: Present code redemption sheet @override Future presentCodeRedemptionSheetIOS() async { @@ -521,9 +814,6 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to present code redemption sheet: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } @@ -544,9 +834,6 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to show manage subscriptions: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } @@ -563,8 +850,8 @@ class FlutterInappPurchase if (platformRaw is String) { final v = platformRaw.toLowerCase(); platform = (v == 'android') - ? iap_types.IapPlatform.android - : iap_types.IapPlatform.ios; + ? iap_types.IapPlatform.Android + : iap_types.IapPlatform.IOS; } else if (platformRaw is iap_types.IapPlatform) { platform = platformRaw; } else { @@ -577,173 +864,137 @@ class FlutterInappPurchase json.containsKey('jsonRepresentationIOS') || json.containsKey('environmentIOS'); if (looksAndroid && !looksIOS) { - platform = iap_types.IapPlatform.android; + platform = iap_types.IapPlatform.Android; } else if (looksIOS && !looksAndroid) { - platform = iap_types.IapPlatform.ios; + platform = iap_types.IapPlatform.IOS; } else { // Fallback to current runtime platform platform = _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android; + ? iap_types.IapPlatform.IOS + : iap_types.IapPlatform.Android; } } - if (type == iap_types.ProductType.subs) { - return iap_types.ProductSubscription( - id: (json['id']?.toString() ?? + final productId = (json['id']?.toString() ?? json['productId']?.toString() ?? json['sku']?.toString() ?? json['productIdentifier']?.toString() ?? - ''), - price: json['price']?.toString() ?? '0', - currency: json['currency']?.toString(), - localizedPrice: json['localizedPrice']?.toString(), - title: json['title']?.toString(), - description: json['description']?.toString(), - type: json['type']?.toString() ?? iap_types.ProductType.subs, - platform: platform, - // iOS fields - displayName: json['displayName']?.toString(), - displayPrice: json['displayPrice']?.toString(), - discountsIOS: _parseDiscountsIOS(json['discounts']), - subscription: json['subscription'] != null - ? iap_types.SubscriptionInfo.fromJson( - Map.from(json['subscription'] as Map), - ) - : json['subscriptionGroupIdIOS'] != null - ? iap_types.SubscriptionInfo( - subscriptionGroupId: - json['subscriptionGroupIdIOS']?.toString(), - ) - : null, - subscriptionGroupIdIOS: json['subscriptionGroupIdIOS']?.toString(), - subscriptionPeriodUnitIOS: - json['subscriptionPeriodUnitIOS']?.toString(), - subscriptionPeriodNumberIOS: - json['subscriptionPeriodNumberIOS']?.toString(), - introductoryPricePaymentModeIOS: - json['introductoryPricePaymentModeIOS']?.toString(), - introductoryPriceNumberOfPeriodsIOS: - json['introductoryPriceNumberOfPeriodsIOS']?.toString(), - introductoryPriceSubscriptionPeriodIOS: - json['introductoryPriceSubscriptionPeriodIOS']?.toString(), - environmentIOS: json['environmentIOS']?.toString(), - promotionalOfferIdsIOS: json['promotionalOfferIdsIOS'] != null - ? (json['promotionalOfferIdsIOS'] as List) - .map((e) => e.toString()) - .toList() - : null, - // OpenIAP compliant iOS fields - isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool? ?? - (json['isFamilyShareable'] as bool?), - jsonRepresentationIOS: json['jsonRepresentationIOS']?.toString() ?? - json['jsonRepresentation']?.toString(), - // Android fields - nameAndroid: json['nameAndroid']?.toString(), - oneTimePurchaseOfferDetailsAndroid: - json['oneTimePurchaseOfferDetailsAndroid'] != null - ? Map.from( - json['oneTimePurchaseOfferDetailsAndroid'] as Map, - ) - : null, - originalPrice: json['originalPrice']?.toString(), - originalPriceAmount: (json['originalPriceAmount'] as num?)?.toDouble(), - freeTrialPeriod: json['freeTrialPeriod']?.toString(), - iconUrl: json['iconUrl']?.toString(), - subscriptionOfferDetailsAndroid: _parseOfferDetails( - json['subscriptionOfferDetailsAndroid'], - ), - subscriptionOffersAndroid: json['subscriptionOffersAndroid'] != null - ? (json['subscriptionOffersAndroid'] as List) - .map((item) { - final Map offer; - if (item is Map) { - offer = item; - } else if (item is Map) { - offer = Map.from(item); - } else { - return null; - } - return iap_types.SubscriptionOfferAndroid.fromJson(offer); - }) - .whereType() - .toList() - : null, - ); - } else { - // For iOS platform, create ProductIOS instance to capture iOS-specific fields - if (platform == iap_types.IapPlatform.ios) { - return iap_types.ProductIOS( - id: (json['id']?.toString() ?? - json['productId']?.toString() ?? - json['sku']?.toString() ?? - json['productIdentifier']?.toString() ?? - ''), - price: json['price']?.toString() ?? '0', - currency: json['currency']?.toString(), - localizedPrice: json['localizedPrice']?.toString(), - title: json['title']?.toString(), - description: json['description']?.toString(), - type: json['type']?.toString() ?? iap_types.ProductType.inapp, + '') + .trim(); + final title = json['title']?.toString() ?? productId; + final description = json['description']?.toString() ?? ''; + final currency = json['currency']?.toString() ?? ''; + final displayPrice = json['displayPrice']?.toString() ?? + json['localizedPrice']?.toString() ?? + '0'; + final priceValue = _parsePrice(json['price']); + final productType = _parseProductType(type); + + if (productType == iap_types.ProductType.Subs) { + if (platform == iap_types.IapPlatform.IOS) { + return iap_types.ProductSubscriptionIOS( + currency: currency, + description: description, + displayNameIOS: json['displayNameIOS']?.toString() ?? title, + displayPrice: displayPrice, + id: productId, + isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool? ?? false, + jsonRepresentationIOS: + json['jsonRepresentationIOS']?.toString() ?? '{}', + platform: platform, + title: title, + type: productType, + typeIOS: _parseProductTypeIOS(json['typeIOS']?.toString()), + debugDescription: json['debugDescription']?.toString(), + discountsIOS: + _parseDiscountsIOS(json['discountsIOS'] ?? json['discounts']), displayName: json['displayName']?.toString(), - // OpenIAP compliant iOS fields - isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool? ?? - (json['isFamilyShareable'] as bool?), - jsonRepresentationIOS: json['jsonRepresentationIOS']?.toString() ?? - json['jsonRepresentation']?.toString(), - // Other iOS fields - discounts: _parseDiscountsIOS(json['discounts']), - subscriptionGroupIdentifier: - json['subscriptionGroupIdIOS']?.toString(), - subscriptionPeriodUnit: json['subscriptionPeriodUnitIOS']?.toString(), - subscriptionPeriodNumber: - json['subscriptionPeriodNumberIOS']?.toString(), - introductoryPricePaymentMode: - json['introductoryPricePaymentModeIOS']?.toString(), + introductoryPriceAsAmountIOS: + json['introductoryPriceAsAmountIOS']?.toString(), + introductoryPriceIOS: json['introductoryPriceIOS']?.toString(), introductoryPriceNumberOfPeriodsIOS: json['introductoryPriceNumberOfPeriodsIOS']?.toString(), - introductoryPriceSubscriptionPeriodIOS: - json['introductoryPriceSubscriptionPeriodIOS']?.toString(), - environment: json['environmentIOS']?.toString(), - promotionalOfferIds: json['promotionalOfferIdsIOS'] != null - ? (json['promotionalOfferIdsIOS'] as List) - .map((e) => e.toString()) - .toList() - : null, - ); - } else { - // For Android platform, create regular Product - return iap_types.Product( - id: (json['id']?.toString() ?? - json['productId']?.toString() ?? - json['sku']?.toString() ?? - json['productIdentifier']?.toString() ?? - ''), - priceString: json['price']?.toString() ?? '0', - currency: json['currency']?.toString(), - localizedPrice: json['localizedPrice']?.toString(), - title: json['title']?.toString(), - description: json['description']?.toString(), - type: json['type']?.toString() ?? iap_types.ProductType.inapp, - platformEnum: platform, - // Android fields - displayName: json['displayName']?.toString(), - displayPrice: json['displayPrice']?.toString(), - nameAndroid: json['nameAndroid']?.toString(), - oneTimePurchaseOfferDetailsAndroid: - json['oneTimePurchaseOfferDetailsAndroid'] != null - ? Map.from( - json['oneTimePurchaseOfferDetailsAndroid'] as Map, - ) - : null, - originalPrice: json['originalPrice']?.toString(), - originalPriceAmount: - (json['originalPriceAmount'] as num?)?.toDouble(), - freeTrialPeriod: json['freeTrialPeriod']?.toString(), - iconUrl: json['iconUrl']?.toString(), + introductoryPricePaymentModeIOS: + _parsePaymentMode(json['introductoryPricePaymentModeIOS']), + introductoryPriceSubscriptionPeriodIOS: _parseSubscriptionPeriod( + json['introductoryPriceSubscriptionPeriodIOS']), + price: priceValue, + subscriptionInfoIOS: _parseSubscriptionInfoIOS( + json['subscriptionInfoIOS'] ?? json['subscription'], + ), + subscriptionPeriodNumberIOS: + json['subscriptionPeriodNumberIOS']?.toString(), + subscriptionPeriodUnitIOS: + _parseSubscriptionPeriod(json['subscriptionPeriodUnitIOS']), ); } + + final subscriptionOffers = _parseOfferDetails( + json['subscriptionOfferDetailsAndroid'], + ); + + return iap_types.ProductSubscriptionAndroid( + currency: currency, + description: description, + displayPrice: displayPrice, + id: productId, + nameAndroid: json['nameAndroid']?.toString() ?? productId, + platform: platform, + subscriptionOfferDetailsAndroid: subscriptionOffers, + title: title, + type: productType, + debugDescription: json['debugDescription']?.toString(), + displayName: json['displayName']?.toString(), + oneTimePurchaseOfferDetailsAndroid: _parseOneTimePurchaseOfferDetail( + json['oneTimePurchaseOfferDetailsAndroid']), + price: priceValue, + ); } + + if (platform == iap_types.IapPlatform.IOS) { + return iap_types.ProductIOS( + currency: currency, + description: description, + displayNameIOS: json['displayNameIOS']?.toString() ?? title, + displayPrice: displayPrice, + id: productId, + isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool? ?? false, + jsonRepresentationIOS: + json['jsonRepresentationIOS']?.toString() ?? '{}', + platform: platform, + title: title, + type: productType, + typeIOS: _parseProductTypeIOS(json['typeIOS']?.toString()), + debugDescription: json['debugDescription']?.toString(), + displayName: json['displayName']?.toString(), + price: priceValue, + subscriptionInfoIOS: _parseSubscriptionInfoIOS( + json['subscriptionInfoIOS'] ?? json['subscription'], + ), + ); + } + + final androidOffers = _parseOfferDetails( + json['subscriptionOfferDetailsAndroid'], + ); + + return iap_types.ProductAndroid( + currency: currency, + description: description, + displayPrice: displayPrice, + id: productId, + nameAndroid: json['nameAndroid']?.toString() ?? productId, + platform: platform, + title: title, + type: productType, + debugDescription: json['debugDescription']?.toString(), + displayName: json['displayName']?.toString(), + oneTimePurchaseOfferDetailsAndroid: _parseOneTimePurchaseOfferDetail( + json['oneTimePurchaseOfferDetailsAndroid']), + price: priceValue, + subscriptionOfferDetailsAndroid: + androidOffers.isEmpty ? null : androidOffers, + ); } List? _parseDiscountsIOS(dynamic json) { @@ -758,8 +1009,12 @@ class FlutterInappPurchase .toList(); } - List? _parseOfferDetails(dynamic json) { - if (json == null) return null; + List _parseOfferDetails( + dynamic json, + ) { + if (json == null) { + return const []; + } // Handle both List and String (JSON string from Android) List list; @@ -767,15 +1022,17 @@ class FlutterInappPurchase // Parse JSON string from Android try { final parsed = jsonDecode(json); - if (parsed is! List) return null; + if (parsed is! List) { + return const []; + } list = parsed; } catch (e) { - return null; + return const []; } } else if (json is List) { list = json; } else { - return null; + return const []; } return list @@ -791,20 +1048,25 @@ class FlutterInappPurchase return null; } - return iap_types.OfferDetail( - offerId: e['offerId'] as String?, + return iap_types.ProductSubscriptionAndroidOfferDetails( basePlanId: e['basePlanId'] as String? ?? '', - offerToken: e['offerToken'] as String?, - pricingPhases: _parsePricingPhases(e['pricingPhases']) ?? [], - offerTags: (e['offerTags'] as List?)?.cast(), + offerId: e['offerId'] as String?, + offerToken: e['offerToken'] as String? ?? '', + offerTags: (e['offerTags'] as List?) + ?.map((tag) => tag.toString()) + .toList() ?? + const [], + pricingPhases: _parsePricingPhases(e['pricingPhases']), ); }) - .whereType() + .whereType() .toList(); } - List? _parsePricingPhases(dynamic json) { - if (json == null) return null; + iap_types.PricingPhasesAndroid _parsePricingPhases(dynamic json) { + if (json == null) { + return const iap_types.PricingPhasesAndroid(pricingPhaseList: []); + } // Handle nested structure from Android List? list; @@ -812,13 +1074,13 @@ class FlutterInappPurchase list = json['pricingPhaseList'] as List?; } else if (json is List) { list = json; - } else { - return null; } - if (list == null) return null; + if (list == null) { + return const iap_types.PricingPhasesAndroid(pricingPhaseList: []); + } - return list + final phases = list .map((item) { // Convert Map to Map final Map e; @@ -831,58 +1093,68 @@ class FlutterInappPurchase return null; } - // Handle priceAmountMicros as either String or num and scale to currency units final priceAmountMicros = e['priceAmountMicros']; - double priceAmount = 0.0; - if (priceAmountMicros != null) { - final double micros = priceAmountMicros is num - ? priceAmountMicros.toDouble() - : (priceAmountMicros is String - ? double.tryParse(priceAmountMicros) ?? 0.0 - : 0.0); - priceAmount = - micros / 1000000.0; // Convert micros to currency units - } - - // Map recurrenceMode if present (BillingClient: 1=infinite, 2=finite, 3=non-recurring) - iap_types.RecurrenceMode? recurrenceMode; - final rm = e['recurrenceMode']; - if (rm is int) { - switch (rm) { - case 1: - recurrenceMode = iap_types.RecurrenceMode.infiniteRecurring; - break; - case 2: - recurrenceMode = iap_types.RecurrenceMode.finiteRecurring; - break; - case 3: - recurrenceMode = iap_types.RecurrenceMode.nonRecurring; - break; - } - } - - return iap_types.PricingPhase( - priceAmount: priceAmount, - price: e['formattedPrice'] as String? ?? '0', - currency: e['priceCurrencyCode'] as String? ?? 'USD', - billingPeriod: e['billingPeriod'] as String?, - billingCycleCount: e['billingCycleCount'] as int?, - recurrenceMode: recurrenceMode, + final recurrenceMode = e['recurrenceMode']; + + return iap_types.PricingPhaseAndroid( + billingCycleCount: (e['billingCycleCount'] as num?)?.toInt() ?? 0, + billingPeriod: e['billingPeriod']?.toString() ?? '', + formattedPrice: e['formattedPrice']?.toString() ?? '0', + priceAmountMicros: priceAmountMicros?.toString() ?? '0', + priceCurrencyCode: e['priceCurrencyCode']?.toString() ?? 'USD', + recurrenceMode: recurrenceMode is int ? recurrenceMode : 0, ); }) - .whereType() + .whereType() .toList(); + + return iap_types.PricingPhasesAndroid(pricingPhaseList: phases); + } + + iap_types.PurchaseState _parsePurchaseStateIOS(dynamic value) { + if (value is iap_types.PurchaseState) return value; + if (value is String) { + switch (value.toLowerCase()) { + case 'purchasing': + case 'pending': + return iap_types.PurchaseState.Pending; + case 'purchased': + case 'restored': + return iap_types.PurchaseState.Purchased; + case 'failed': + return iap_types.PurchaseState.Failed; + case 'deferred': + return iap_types.PurchaseState.Deferred; + default: + return iap_types.PurchaseState.Unknown; + } + } + if (value is num) { + switch (value.toInt()) { + case 0: + return iap_types.PurchaseState.Pending; + case 1: + return iap_types.PurchaseState.Purchased; + case 2: + return iap_types.PurchaseState.Failed; + case 3: + return iap_types.PurchaseState.Purchased; + case 4: + return iap_types.PurchaseState.Deferred; + } + } + return iap_types.PurchaseState.Unknown; } iap_types.PurchaseState _mapAndroidPurchaseState(int stateValue) { - final state = AndroidPurchaseState.fromValue(stateValue); + final state = androidPurchaseStateFromValue(stateValue); switch (state) { - case AndroidPurchaseState.purchased: - return iap_types.PurchaseState.purchased; - case AndroidPurchaseState.pending: - return iap_types.PurchaseState.pending; - case AndroidPurchaseState.unspecified: - return iap_types.PurchaseState.unspecified; + case AndroidPurchaseState.Purchased: + return iap_types.PurchaseState.Purchased; + case AndroidPurchaseState.Pending: + return iap_types.PurchaseState.Pending; + case AndroidPurchaseState.Unknown: + return iap_types.PurchaseState.Unknown; } } @@ -890,188 +1162,127 @@ class FlutterInappPurchase Map itemJson, [ Map? originalJson, ]) { - // Map iOS transaction state string to enum - iap_types.TransactionState? transactionStateIOS; - final transactionStateIOSValue = itemJson['transactionStateIOS']; - if (transactionStateIOSValue != null) { - switch (transactionStateIOSValue) { - case '0': - case 'purchasing': - transactionStateIOS = iap_types.TransactionState.purchasing; - break; - case '1': - case 'purchased': - transactionStateIOS = iap_types.TransactionState.purchased; - break; - case '2': - case 'failed': - transactionStateIOS = iap_types.TransactionState.failed; - break; - case '3': - case 'restored': - transactionStateIOS = iap_types.TransactionState.restored; - break; - case '4': - case 'deferred': - transactionStateIOS = iap_types.TransactionState.deferred; - break; - } + final productId = itemJson['productId']?.toString() ?? ''; + final transactionId = + itemJson['transactionId']?.toString() ?? itemJson['id']?.toString(); + final quantity = (itemJson['quantity'] as num?)?.toInt() ?? 1; + + final String? purchaseId = (transactionId?.isNotEmpty ?? false) + ? transactionId + : (productId.isNotEmpty ? productId : null); + + if (purchaseId == null || purchaseId.isEmpty) { + debugPrint( + '[flutter_inapp_purchase] Skipping purchase with missing identifiers: $itemJson', + ); + throw const FormatException('Missing purchase identifier'); } - // Convert transactionDate to timestamp (milliseconds) - int? transactionDateTimestamp; + double transactionDate = 0; final transactionDateValue = itemJson['transactionDate']; - if (transactionDateValue != null) { - if (transactionDateValue is num) { - transactionDateTimestamp = transactionDateValue.toInt(); - } else if (transactionDateValue is String) { - final date = DateTime.tryParse(transactionDateValue); - transactionDateTimestamp = date?.millisecondsSinceEpoch; + if (transactionDateValue is num) { + transactionDate = transactionDateValue.toDouble(); + } else if (transactionDateValue is String) { + final parsedDate = DateTime.tryParse(transactionDateValue); + if (parsedDate != null) { + transactionDate = parsedDate.millisecondsSinceEpoch.toDouble(); } } - // Parse original transaction date for iOS to integer timestamp - int? originalTransactionDateIOS; - final originalTransactionDateIOSValue = - itemJson['originalTransactionDateIOS']; - if (originalTransactionDateIOSValue != null) { - try { - // Try parsing as ISO string first - final date = DateTime.tryParse( - originalTransactionDateIOSValue.toString(), - ); - if (date != null) { - originalTransactionDateIOS = date.millisecondsSinceEpoch; - } else { - // Try parsing as number string - originalTransactionDateIOS = int.tryParse( - originalTransactionDateIOSValue.toString(), - ); - } - } catch (e) { - // Try parsing as number string - originalTransactionDateIOS = int.tryParse( - originalTransactionDateIOSValue.toString(), - ); + if (_platform.isAndroid) { + final stateValue = itemJson['purchaseStateAndroid'] as int? ?? + itemJson['purchaseState'] as int? ?? + 1; + final purchaseState = _mapAndroidPurchaseState(stateValue).toJson(); + + final map = { + 'id': purchaseId, + 'productId': productId, + 'platform': iap_types.IapPlatform.Android.toJson(), + 'isAutoRenewing': itemJson['isAutoRenewing'] as bool? ?? + itemJson['autoRenewingAndroid'] as bool? ?? + false, + 'purchaseState': purchaseState, + 'quantity': quantity, + 'transactionDate': transactionDate, + 'purchaseToken': itemJson['purchaseToken']?.toString(), + 'autoRenewingAndroid': itemJson['autoRenewingAndroid'] as bool?, + 'dataAndroid': itemJson['originalJsonAndroid']?.toString(), + 'developerPayloadAndroid': + itemJson['developerPayloadAndroid']?.toString(), + 'ids': (itemJson['ids'] as List?) + ?.map((e) => e.toString()) + .toList(), + 'isAcknowledgedAndroid': itemJson['isAcknowledgedAndroid'] as bool?, + 'obfuscatedAccountIdAndroid': + itemJson['obfuscatedAccountIdAndroid']?.toString() ?? + originalJson?['obfuscatedAccountIdAndroid']?.toString(), + 'obfuscatedProfileIdAndroid': + itemJson['obfuscatedProfileIdAndroid']?.toString() ?? + originalJson?['obfuscatedProfileIdAndroid']?.toString(), + 'packageNameAndroid': itemJson['packageNameAndroid']?.toString(), + 'signatureAndroid': itemJson['signatureAndroid']?.toString(), + }; + + return iap_types.PurchaseAndroid.fromJson(map); + } + + final stateIOS = _parsePurchaseStateIOS( + itemJson['purchaseState'] ?? itemJson['transactionStateIOS'], + ).toJson(); + + double? originalTransactionDateIOS; + final originalTransactionDateValue = + itemJson['originalTransactionDateIOS'] ?? + originalJson?['originalTransactionDateIOS']; + if (originalTransactionDateValue is num) { + originalTransactionDateIOS = originalTransactionDateValue.toDouble(); + } else if (originalTransactionDateValue is String) { + final parsed = DateTime.tryParse(originalTransactionDateValue); + if (parsed != null) { + originalTransactionDateIOS = parsed.millisecondsSinceEpoch.toDouble(); } } - // Convert transactionId to string - final convertedTransactionId = - itemJson['id']?.toString() ?? itemJson['transactionId']?.toString(); - - return iap_types.Purchase( - productId: itemJson['productId']?.toString() ?? '', - // Convert transactionId to string for OpenIAP compliance - // The id getter will return transactionId (OpenIAP compliant) - transactionId: convertedTransactionId, - transactionReceipt: itemJson['transactionReceipt']?.toString(), - purchaseToken: itemJson['purchaseToken']?.toString(), - // Use timestamp integer for OpenIAP compliance - transactionDate: transactionDateTimestamp, - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, - // iOS specific fields - transactionStateIOS: _platform.isIOS ? transactionStateIOS : null, - originalTransactionIdentifierIOS: _platform.isIOS - ? itemJson['originalTransactionIdentifierIOS']?.toString() - : null, - originalTransactionDateIOS: - _platform.isIOS ? originalTransactionDateIOS?.toString() : null, - quantityIOS: - _platform.isIOS ? (originalJson?['quantityIOS'] as int? ?? 1) : null, - // Additional iOS subscription fields from originalJson - environmentIOS: - _platform.isIOS ? (originalJson?['environmentIOS'] as String?) : null, - expirationDateIOS: - _platform.isIOS && originalJson?['expirationDateIOS'] != null - ? DateTime.fromMillisecondsSinceEpoch( - (originalJson!['expirationDateIOS'] as num).toInt(), - ) - : null, - subscriptionGroupIdIOS: _platform.isIOS - ? (originalJson?['subscriptionGroupIdIOS'] as String?) - : null, - productTypeIOS: - _platform.isIOS ? (originalJson?['productTypeIOS'] as String?) : null, - transactionReasonIOS: - _platform.isIOS ? (originalJson?['reasonIOS'] as String?) : null, - currencyCodeIOS: - _platform.isIOS ? (originalJson?['currencyIOS'] as String?) : null, - storeFrontCountryCodeIOS: _platform.isIOS - ? (originalJson?['storefrontCountryCodeIOS'] as String?) - : null, - appBundleIdIOS: - _platform.isIOS ? (originalJson?['appBundleIdIOS'] as String?) : null, - isUpgradedIOS: - _platform.isIOS ? (originalJson?['isUpgradedIOS'] as bool?) : null, - ownershipTypeIOS: _platform.isIOS - ? (originalJson?['ownershipTypeIOS'] as String?) - : null, - reasonIOS: - _platform.isIOS ? (originalJson?['reasonIOS'] as String?) : null, - webOrderLineItemIdIOS: _platform.isIOS - ? (originalJson?['webOrderLineItemIdIOS'] as String?) - : null, - offerIOS: _platform.isIOS && originalJson?['offerIOS'] != null - ? (originalJson!['offerIOS'] is Map - ? originalJson['offerIOS'] as Map - : Map.from(originalJson['offerIOS'] as Map)) - : null, - priceIOS: _platform.isIOS && originalJson?['priceIOS'] != null - ? (originalJson!['priceIOS'] as num).toDouble() - : null, - revocationDateIOS: - _platform.isIOS && originalJson?['revocationDateIOS'] != null - ? DateTime.fromMillisecondsSinceEpoch( - (originalJson!['revocationDateIOS'] as num).toInt(), - ) - : null, - revocationReasonIOS: _platform.isIOS - ? (originalJson?['revocationReasonIOS'] as String?) - : null, - // Android specific fields - isAcknowledgedAndroid: _platform.isAndroid - ? itemJson['isAcknowledgedAndroid'] as bool? - : null, - purchaseState: - _platform.isAndroid && itemJson['purchaseStateAndroid'] != null - ? _mapAndroidPurchaseState( - itemJson['purchaseStateAndroid'] as int, - ) - : null, - purchaseStateAndroid: - _platform.isAndroid ? itemJson['purchaseStateAndroid'] as int? : null, - originalJson: _platform.isAndroid - ? itemJson['originalJsonAndroid']?.toString() - : null, - dataAndroid: _platform.isAndroid - ? itemJson['originalJsonAndroid']?.toString() - : null, - signatureAndroid: - _platform.isAndroid ? itemJson['signatureAndroid']?.toString() : null, - packageNameAndroid: _platform.isAndroid - ? itemJson['packageNameAndroid']?.toString() - : null, - autoRenewingAndroid: - _platform.isAndroid ? itemJson['autoRenewingAndroid'] as bool? : null, - developerPayloadAndroid: _platform.isAndroid - ? itemJson['developerPayloadAndroid']?.toString() - : null, - orderIdAndroid: - _platform.isAndroid ? itemJson['orderId']?.toString() : null, - obfuscatedAccountIdAndroid: _platform.isAndroid - ? (originalJson?['obfuscatedAccountIdAndroid'] as String?) - : null, - obfuscatedProfileIdAndroid: _platform.isAndroid - ? (originalJson?['obfuscatedProfileIdAndroid'] as String?) - : null, - ); + final map = { + 'id': purchaseId, + 'productId': productId, + 'platform': iap_types.IapPlatform.IOS.toJson(), + 'isAutoRenewing': itemJson['isAutoRenewing'] as bool? ?? false, + 'purchaseState': stateIOS, + 'quantity': quantity, + 'transactionDate': transactionDate, + 'purchaseToken': itemJson['transactionReceipt']?.toString() ?? + itemJson['purchaseToken']?.toString(), + 'ids': (itemJson['ids'] as List?) + ?.map((e) => e.toString()) + .toList(), + 'appAccountToken': itemJson['appAccountToken']?.toString(), + 'appBundleIdIOS': itemJson['appBundleIdIOS']?.toString(), + 'countryCodeIOS': itemJson['countryCodeIOS']?.toString(), + 'currencyCodeIOS': itemJson['currencyCodeIOS']?.toString(), + 'currencySymbolIOS': itemJson['currencySymbolIOS']?.toString(), + 'environmentIOS': itemJson['environmentIOS']?.toString(), + 'expirationDateIOS': + (originalJson?['expirationDateIOS'] as num?)?.toDouble(), + 'originalTransactionIdentifierIOS': + itemJson['originalTransactionIdentifierIOS']?.toString(), + 'originalTransactionDateIOS': originalTransactionDateIOS, + 'subscriptionGroupIdIOS': itemJson['subscriptionGroupIdIOS']?.toString(), + 'transactionReasonIOS': itemJson['transactionReasonIOS']?.toString(), + 'webOrderLineItemIdIOS': itemJson['webOrderLineItemIdIOS']?.toString(), + 'offerIOS': originalJson?['offerIOS'], + 'priceIOS': (originalJson?['priceIOS'] as num?)?.toDouble(), + 'revocationDateIOS': + (originalJson?['revocationDateIOS'] as num?)?.toDouble(), + 'revocationReasonIOS': originalJson?['revocationReasonIOS']?.toString(), + }; + + return iap_types.PurchaseIOS.fromJson(map); } iap_types.PurchaseError _convertToPurchaseError( - iap_types.PurchaseResult result, + PurchaseResult result, ) { iap_types.ErrorCode code = iap_types.ErrorCode.Unknown; @@ -1080,8 +1291,8 @@ class FlutterInappPurchase final detected = iap_err.ErrorCodeUtils.fromPlatformCode( result.code!, _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, + ? iap_types.IapPlatform.IOS + : iap_types.IapPlatform.Android, ); if (detected != iap_types.ErrorCode.Unknown) { code = detected; @@ -1114,10 +1325,10 @@ class FlutterInappPurchase code = iap_types.ErrorCode.Unknown; break; case 7: - code = iap_types.ErrorCode.ProductAlreadyOwned; + code = iap_types.ErrorCode.AlreadyOwned; break; case 8: - code = iap_types.ErrorCode.PurchaseNotAllowed; + code = iap_types.ErrorCode.PurchaseError; break; } } @@ -1125,10 +1336,6 @@ class FlutterInappPurchase return iap_types.PurchaseError( code: code, message: result.message ?? 'Unknown error', - debugMessage: result.debugMessage, - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } @@ -1200,7 +1407,6 @@ class FlutterInappPurchase 'Replacement modes are only for upgrading/downgrading EXISTING subscriptions. ' 'For NEW subscriptions, do not set replacementModeAndroid or set it to -1. ' 'To upgrade/downgrade, provide the purchaseToken from getAvailablePurchases().', - platform: iap_types.IapPlatform.android, ); } @@ -1238,53 +1444,51 @@ class FlutterInappPurchase } @override - Future consumePurchaseAndroid({required String purchaseToken}) async { + Future consumePurchaseAndroid({ + required String purchaseToken, + }) async { if (!_platform.isAndroid) { - return false; + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.IapNotAvailable, + message: 'consumePurchaseAndroid is only available on Android', + ); } try { - final result = await channel.invokeMethod('consumePurchase', { - 'purchaseToken': purchaseToken, - }); - return result ?? false; + final response = await _channel.invokeMethod>( + 'consumePurchaseAndroid', + {'purchaseToken': purchaseToken}, + ); + if (response == null) { + return const iap_types.VoidResult(success: false); + } + return iap_types.VoidResult.fromJson( + Map.from(response), + ); } catch (error) { debugPrint('Error consuming purchase: $error'); - return false; + return const iap_types.VoidResult(success: false); } } - /// End connection - /* removed in 6.6.0 */ Future finalize() async { - if (_platform.isAndroid) { - } else if (_platform.isIOS) { - final String? result = await _channel.invokeMethod('endConnection'); - _removePurchaseListener(); - return result; - } - throw PlatformException( - code: _platform.operatingSystem, - message: 'platform not supported', - ); - } - /// Finish a transaction using Purchase object (OpenIAP compliant) Future finishTransaction( iap_types.Purchase purchase, { bool isConsumable = false, }) async { // Use purchase.id (OpenIAP standard) if available, fallback to transactionId for backward compatibility - final transactionId = - purchase.id.isNotEmpty ? purchase.id : purchase.transactionId; + final transactionId = purchase.id; if (_platform.isAndroid) { + final androidPurchase = purchase as iap_types.PurchaseAndroid; + if (isConsumable) { debugPrint( - '[FlutterInappPurchase] Android: Consuming product with token: ${purchase.purchaseToken}', + '[FlutterInappPurchase] Android: Consuming product with token: ${androidPurchase.purchaseToken}', ); final result = await _channel.invokeMethod( 'consumePurchaseAndroid', - {'purchaseToken': purchase.purchaseToken}, + {'purchaseToken': androidPurchase.purchaseToken}, ); parseAndLogAndroidResponse( result, @@ -1295,7 +1499,7 @@ class FlutterInappPurchase ); return; } else { - if (purchase.isAcknowledgedAndroid == true) { + if (androidPurchase.isAcknowledgedAndroid == true) { if (kDebugMode) { debugPrint( '[FlutterInappPurchase] Android: Purchase already acknowledged', @@ -1304,7 +1508,8 @@ class FlutterInappPurchase return; } else { if (kDebugMode) { - final maskedToken = (purchase.purchaseToken ?? '').replaceAllMapped( + final maskedToken = + (androidPurchase.purchaseToken ?? '').replaceAllMapped( RegExp(r'.(?=.{4})'), (m) => '*', ); @@ -1313,13 +1518,14 @@ class FlutterInappPurchase ); } // Subscriptions use legacy acknowledgePurchase for compatibility - final methodName = (purchase.autoRenewingAndroid == true || - purchase.autoRenewing == true) + final methodName = androidPurchase.autoRenewingAndroid == true ? 'acknowledgePurchase' : 'acknowledgePurchaseAndroid'; final result = await _channel.invokeMethod( methodName, - {'purchaseToken': purchase.purchaseToken}, + { + 'purchaseToken': androidPurchase.purchaseToken, + }, ); parseAndLogAndroidResponse( result, @@ -1426,26 +1632,23 @@ class FlutterInappPurchase required String sku, }) async { if (!_platform.isIOS) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Receipt validation is only available on iOS', - platform: iap_types.IapPlatform.ios, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.IapNotAvailable, + message: 'Receipt validation is only available on iOS', ); } if (!_isInitialized) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'IAP connection not initialized', - platform: iap_types.IapPlatform.ios, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.NotPrepared, + message: 'IAP connection not initialized', ); } if (sku.trim().isEmpty) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'sku cannot be empty', - platform: iap_types.IapPlatform.ios, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.DeveloperError, + message: 'sku cannot be empty', ); } @@ -1456,10 +1659,9 @@ class FlutterInappPurchase ); if (result == null) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'No validation result received from native platform', - platform: iap_types.IapPlatform.ios, // This is iOS validation + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.ServiceError, + message: 'No validation result received from native platform', ); } @@ -1469,37 +1671,30 @@ class FlutterInappPurchase ); // Parse latestTransaction if present - Map? latestTransaction; - if (validationResult['latestTransaction'] != null) { - latestTransaction = Map.from( - validationResult['latestTransaction'] as Map, - ); - } - - return iap_types.ReceiptValidationResult( + final latestTransactionMap = validationResult['latestTransaction']; + final latestTransaction = latestTransactionMap is Map + ? iap_types.Purchase.fromJson( + Map.from(latestTransactionMap), + ) + : null; + + return iap_types.ReceiptValidationResultIOS( isValid: validationResult['isValid'] as bool? ?? false, - errorMessage: validationResult['errorMessage'] as String?, - receiptData: validationResult['receiptData'] as String?, - purchaseToken: - validationResult['purchaseToken'] as String?, // Unified field - jwsRepresentation: validationResult['jwsRepresentation'] - as String?, // Deprecated, for backward compatibility + jwsRepresentation: + validationResult['jwsRepresentation']?.toString() ?? '', + receiptData: validationResult['receiptData']?.toString() ?? '', latestTransaction: latestTransaction, - rawResponse: validationResult, - platform: iap_types.IapPlatform.ios, ); } on PlatformException catch (e) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: + throw iap_types.PurchaseError( + code: iap_types.ErrorCode.ServiceError, + message: 'Failed to validate receipt [${e.code}]: ${e.message ?? e.details}', - platform: iap_err.getCurrentPlatform(), ); } catch (e) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Failed to validate receipt: ${e.toString()}', - platform: iap_err.getCurrentPlatform(), + throw iap_types.PurchaseError( + code: iap_types.ErrorCode.ServiceError, + message: 'Failed to validate receipt: ${e.toString()}', ); } } @@ -1560,10 +1755,9 @@ class FlutterInappPurchase } else if (_platform.isAndroid) { return _validateReceiptAndroid(options: options); } else { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Platform not supported for receipt validation', - platform: null, // Unknown/unsupported platform + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.IapNotAvailable, + message: 'Platform not supported for receipt validation', ); } } @@ -1572,112 +1766,10 @@ class FlutterInappPurchase Future _validateReceiptAndroid({ required iap_types.ReceiptValidationProps options, }) async { - if (!_platform.isAndroid) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Receipt validation is only available on Android', - platform: iap_types.IapPlatform.android, - ); - } - - // Extract Android-specific options - final androidOptions = options.androidOptions; - if (androidOptions == null) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Android options required for Android validation', - platform: iap_types.IapPlatform.android, - ); - } - - final packageName = androidOptions.packageName; - final productToken = androidOptions.productToken; - final accessToken = androidOptions.accessToken; - final isSub = androidOptions.isSub; - - if (packageName.isEmpty || productToken.isEmpty || accessToken.isEmpty) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: - 'Invalid parameters: packageName, productToken, and accessToken cannot be empty', - platform: iap_types.IapPlatform.android, - ); - } - - try { - final type = isSub ? 'subscriptions' : 'products'; - final url = - 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications' - '/$packageName/purchases/$type/${options.sku}' - '/tokens/$productToken'; - - final response = await _client.get( - Uri.parse(url), - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $accessToken', - }, - ).timeout(const Duration(seconds: 10)); - - if (response.statusCode == 200) { - final responseData = json.decode(response.body) as Map; - - bool isValid; - if (isSub) { - // Active if not canceled and not expired - final expiryRaw = responseData['expiryTimeMillis']; - final cancelReason = responseData['cancelReason']; - final expiryMs = expiryRaw is String - ? int.tryParse(expiryRaw) - : (expiryRaw is num ? expiryRaw.toInt() : null); - final nowMs = DateTime.now().millisecondsSinceEpoch; - isValid = (expiryMs != null && expiryMs > nowMs) && - (cancelReason == null || cancelReason == 0); - } else { - // One-time products: 0 = Purchased, 1 = Canceled - final purchaseState = responseData['purchaseState'] as int?; - isValid = purchaseState == 0; - } - - return iap_types.ReceiptValidationResult( - isValid: isValid, - errorMessage: isValid ? null : 'Purchase is not active/valid', - rawResponse: responseData, - platform: iap_err.getCurrentPlatform(), - ); - } else if (response.statusCode == 401 || response.statusCode == 403) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Unauthorized/forbidden (check access token/scopes)', - platform: iap_err.getCurrentPlatform(), - ); - } else if (response.statusCode == 404) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Token or SKU not found', - platform: iap_err.getCurrentPlatform(), - ); - } else { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: - 'API returned status ${response.statusCode}: ${response.body}', - platform: iap_err.getCurrentPlatform(), - ); - } - } on TimeoutException { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Request to Google Play API timed out', - platform: iap_err.getCurrentPlatform(), - ); - } catch (e) { - return iap_types.ReceiptValidationResult( - isValid: false, - errorMessage: 'Failed to validate receipt: ${e.toString()}', - platform: iap_err.getCurrentPlatform(), - ); - } + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.IapNotAvailable, + message: 'Android receipt validation is not supported', + ); } Future _setPurchaseListener() async { @@ -1711,8 +1803,7 @@ class FlutterInappPurchase ); Map result = jsonDecode(call.arguments as String) as Map; - iap_types.PurchaseResult purchaseResult = - iap_types.PurchaseResult.fromJSON(result); + final purchaseResult = PurchaseResult.fromJSON(result); _purchaseErrorController!.add(purchaseResult); // Also emit to Open IAP compatible stream final error = _convertToPurchaseError(purchaseResult); @@ -1725,7 +1816,7 @@ class FlutterInappPurchase Map result = jsonDecode(call.arguments as String) as Map; _connectionController!.add( - iap_types.ConnectionResult.fromJSON(result), + ConnectionResult.fromJSON(Map.from(result)), ); break; case 'iap-promoted-product': @@ -1743,38 +1834,24 @@ class FlutterInappPurchase }); } - Future _removePurchaseListener() async { - _purchaseController - ?..add(null) - ..close(); - _purchaseController = null; - - _purchaseErrorController - ?..add(null) - ..close(); - _purchaseErrorController = null; - } - // flutter IAP compatible methods /// OpenIAP: fetch products or subscriptions Future> fetchProducts({ required List skus, - String type = iap_types.ProductType.inapp, + Object type = TypeInApp.inapp, }) async { if (!_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.NotPrepared, message: 'IAP connection not initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } try { + final resolvedType = _resolveProductType(type); debugPrint( - '[flutter_inapp_purchase] fetchProducts called with skus: $skus, type: $type'); + '[flutter_inapp_purchase] fetchProducts called with skus: $skus, type: $resolvedType'); // Get raw data from native platform final List merged = []; @@ -1782,7 +1859,7 @@ class FlutterInappPurchase // iOS supports 'all' at native layer final raw = await _channel.invokeMethod('fetchProducts', { 'skus': skus, - 'type': type, + 'type': resolvedType, }); if (raw is String) { merged.addAll(jsonDecode(raw) as List? ?? []); @@ -1793,7 +1870,7 @@ class FlutterInappPurchase // Android: unified fetchProducts(type, skus) final raw = await _channel.invokeMethod('fetchProducts', { 'skus': skus, - 'type': type, + 'type': resolvedType, }); if (raw is String) { merged.addAll(jsonDecode(raw) as List? ?? []); @@ -1825,9 +1902,9 @@ class FlutterInappPurchase continue; } // When 'all', native item contains its own type; pass through using detected type - final detectedType = (type == 'all') - ? (itemMap['type']?.toString() ?? iap_types.ProductType.inapp) - : type; + final detectedType = (resolvedType == 'all') + ? (itemMap['type']?.toString() ?? 'in-app') + : resolvedType; final parsed = _parseProductFromNative(itemMap, detectedType); products.add(parsed); } catch (e) { @@ -1848,9 +1925,6 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to fetch products: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } @@ -1889,12 +1963,9 @@ class FlutterInappPurchase List? subscriptionIds, }) async { if (!_isInitialized) { - throw iap_types.PurchaseError( - code: iap_types.ErrorCode.NotInitialized, + throw const iap_types.PurchaseError( + code: iap_types.ErrorCode.NotPrepared, message: 'IAP connection not initialized', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } @@ -1912,36 +1983,45 @@ class FlutterInappPurchase continue; } - // Check if this is a subscription (typically by checking auto-renewing status) - // or by checking the purchase against known subscription products - bool isSubscription = false; - bool isActive = false; - - if (_platform.isAndroid) { - // On Android, check if it's auto-renewing - isSubscription = purchase.autoRenewingAndroid ?? false; - isActive = isSubscription && - (purchase.purchaseState == iap_types.PurchaseState.purchased || - purchase.purchaseState == null); // Allow null for test data - } else if (_platform.isIOS) { - // On iOS, we need to check the transaction state and receipt - // For StoreKit 2, subscriptions should have expiration dates in the receipt - // For testing, also consider it a subscription if it has iOS in the productId - isSubscription = purchase.transactionReceipt != null || - purchase.productId.contains('sub'); - isActive = (purchase.transactionStateIOS == - iap_types.TransactionState.purchased || - purchase.transactionStateIOS == - iap_types.TransactionState.restored || - purchase.transactionStateIOS == null) && + if (purchase is iap_types.PurchaseAndroid) { + final bool isSubscription = purchase.autoRenewingAndroid ?? false; + final bool isActive = isSubscription && + purchase.purchaseState == iap_types.PurchaseState.Purchased; + + if (isSubscription && isActive) { + activeSubscriptions.add( + iap_types.ActiveSubscription( + productId: purchase.productId, + isActive: true, + autoRenewingAndroid: purchase.autoRenewingAndroid ?? false, + transactionDate: purchase.transactionDate, + transactionId: purchase.id, + purchaseToken: purchase.purchaseToken, + ), + ); + } + } else if (purchase is iap_types.PurchaseIOS) { + final receipt = purchase.purchaseToken; + final bool isSubscription = + receipt != null || purchase.productId.contains('sub'); + final bool isActive = (purchase.purchaseState == + iap_types.PurchaseState.Purchased || + purchase.purchaseState == iap_types.PurchaseState.Restored) && isSubscription; - } - if (isSubscription && isActive) { - // Create ActiveSubscription from Purchase - activeSubscriptions.add( - iap_types.ActiveSubscription.fromPurchase(purchase), - ); + if (isSubscription && isActive) { + activeSubscriptions.add( + iap_types.ActiveSubscription( + productId: purchase.productId, + isActive: true, + expirationDateIOS: purchase.expirationDateIOS, + environmentIOS: purchase.environmentIOS, + purchaseToken: purchase.purchaseToken, + transactionDate: purchase.transactionDate, + transactionId: purchase.id, + ), + ); + } } } @@ -1953,9 +2033,6 @@ class FlutterInappPurchase throw iap_types.PurchaseError( code: iap_types.ErrorCode.ServiceError, message: 'Failed to get active subscriptions: ${e.toString()}', - platform: _platform.isIOS - ? iap_types.IapPlatform.ios - : iap_types.IapPlatform.android, ); } } @@ -1997,22 +2074,125 @@ class FlutterInappPurchase list = json.decode(result.toString()) as List; } - List? decoded = list - .map( - (dynamic product) => _convertFromLegacyPurchase( - Map.from(product as Map), - Map.from( - product, - ), // Pass original JSON as well - ), - ) - .toList(); + final purchases = []; + for (final dynamic product in list) { + try { + final map = Map.from(product as Map); + final original = Map.from(product); + purchases.add(_convertFromLegacyPurchase(map, original)); + } catch (error) { + debugPrint( + '[flutter_inapp_purchase] Skipping purchase due to parse error: $error', + ); + } + } + + return purchases; + } + + double? _parsePrice(dynamic value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + iap_types.ProductType _parseProductType(dynamic value) { + if (value is iap_types.ProductType) return value; + final rawUpper = value?.toString().toUpperCase() ?? 'IN_APP'; + final normalized = rawUpper == 'INAPP' ? 'IN_APP' : rawUpper; + try { + return iap_types.ProductType.fromJson(normalized); + } catch (_) { + return normalized.contains('SUB') + ? iap_types.ProductType.Subs + : iap_types.ProductType.InApp; + } + } + + iap_types.ProductTypeIOS _parseProductTypeIOS(String? value) { + final rawUpper = value?.toString().toUpperCase() ?? 'NON_CONSUMABLE'; + final normalized = + rawUpper == 'NONCONSUMABLE' ? 'NON_CONSUMABLE' : rawUpper; + try { + return iap_types.ProductTypeIOS.fromJson(normalized); + } catch (_) { + switch (normalized) { + case 'CONSUMABLE': + return iap_types.ProductTypeIOS.Consumable; + case 'AUTO_RENEWABLE_SUBSCRIPTION': + case 'SUBS': + case 'SUBSCRIPTION': + return iap_types.ProductTypeIOS.AutoRenewableSubscription; + case 'NON_RENEWING_SUBSCRIPTION': + return iap_types.ProductTypeIOS.NonRenewingSubscription; + default: + return iap_types.ProductTypeIOS.NonConsumable; + } + } + } + + iap_types.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) { + if (value is Map) { + return iap_types.SubscriptionInfoIOS.fromJson(value); + } + if (value is Map) { + return iap_types.SubscriptionInfoIOS.fromJson( + Map.from(value), + ); + } + if (value is List && value.isNotEmpty) { + final first = value.first; + if (first is Map) { + return iap_types.SubscriptionInfoIOS.fromJson( + Map.from(first), + ); + } + } + return null; + } - return decoded; + iap_types.SubscriptionPeriodIOS? _parseSubscriptionPeriod(dynamic value) { + if (value == null) return null; + final raw = value.toString().toUpperCase(); + try { + return iap_types.SubscriptionPeriodIOS.fromJson(raw); + } catch (_) { + return null; + } + } + + iap_types.PaymentModeIOS? _parsePaymentMode(dynamic value) { + if (value == null) return null; + final raw = value.toString().toUpperCase(); + try { + return iap_types.PaymentModeIOS.fromJson(raw); + } catch (_) { + return null; + } + } + + iap_types.ProductAndroidOneTimePurchaseOfferDetail? + _parseOneTimePurchaseOfferDetail(dynamic value) { + if (value is Map) { + return iap_types.ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice: value['formattedPrice']?.toString() ?? '0', + priceAmountMicros: value['priceAmountMicros']?.toString() ?? '0', + priceCurrencyCode: value['priceCurrencyCode']?.toString() ?? 'USD', + ); + } + if (value is Map) { + final map = Map.from(value); + return iap_types.ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice: map['formattedPrice']?.toString() ?? '0', + priceAmountMicros: map['priceAmountMicros']?.toString() ?? '0', + priceCurrencyCode: map['priceCurrencyCode']?.toString() ?? 'USD', + ); + } + return null; } } -List? extractResult(dynamic result) { +List? extractResult(dynamic result) { // Handle both JSON string and already decoded List List list; if (result is String) { @@ -2023,9 +2203,9 @@ List? extractResult(dynamic result) { list = json.decode(result.toString()) as List; } - List? decoded = list - .map( - (dynamic product) => iap_types.PurchaseResult.fromJSON( + final decoded = list + .map( + (dynamic product) => PurchaseResult.fromJSON( Map.from(product as Map), ), ) @@ -2033,3 +2213,39 @@ List? extractResult(dynamic result) { return decoded; } + +class PurchaseResult { + PurchaseResult({ + this.responseCode, + this.debugMessage, + this.code, + this.message, + this.purchaseTokenAndroid, + }); + + final int? responseCode; + final String? debugMessage; + final String? code; + final String? message; + final String? purchaseTokenAndroid; + + factory PurchaseResult.fromJSON(Map json) { + return PurchaseResult( + responseCode: json['responseCode'] as int?, + debugMessage: json['debugMessage']?.toString(), + code: json['code']?.toString(), + message: json['message']?.toString(), + purchaseTokenAndroid: json['purchaseTokenAndroid']?.toString(), + ); + } +} + +class ConnectionResult { + ConnectionResult({this.msg}); + + final String? msg; + + factory ConnectionResult.fromJSON(Map json) { + return ConnectionResult(msg: json['msg']?.toString()); + } +} diff --git a/lib/modules/android.dart b/lib/modules/android.dart index 093d32514..f85239e3e 100644 --- a/lib/modules/android.dart +++ b/lib/modules/android.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; -import '../types.dart'; +import '../types.dart' as types; +import '../enums.dart' as legacy; /// Android-specific IAP functionality as a mixin mixin FlutterInappPurchaseAndroid { @@ -12,20 +13,27 @@ mixin FlutterInappPurchaseAndroid { /// Consumes a purchase on Android (for consumable products) /// @param purchaseToken - The purchase token to consume - Future consumePurchaseAndroid({required String purchaseToken}) async { + Future consumePurchaseAndroid( + {required String purchaseToken}) async { if (!isAndroid) { - return false; + throw PlatformException( + code: 'platform', + message: 'consumePurchaseAndroid is only supported on Android', + ); } try { - final result = - await channel.invokeMethod('consumePurchaseAndroid', { - 'purchaseToken': purchaseToken, - }); - return result ?? false; + final response = await channel.invokeMethod>( + 'consumePurchaseAndroid', + {'purchaseToken': purchaseToken}, + ); + if (response == null) { + return const types.VoidResult(success: false); + } + return types.VoidResult.fromJson(Map.from(response)); } catch (error) { debugPrint('Error consuming purchase: $error'); - return false; + return const types.VoidResult(success: false); } } } @@ -34,7 +42,7 @@ mixin FlutterInappPurchaseAndroid { class InAppMessage { final String messageId; final String campaignName; - final InAppMessageType messageType; + final legacy.InAppMessageType messageType; InAppMessage({ required this.messageId, @@ -44,9 +52,10 @@ class InAppMessage { factory InAppMessage.fromMap(Map map) { return InAppMessage( - messageId: map['messageId'] as String, - campaignName: map['campaignName'] as String, - messageType: InAppMessageType.values[map['messageType'] as int], + messageId: map['messageId']?.toString() ?? '', + campaignName: map['campaignName']?.toString() ?? '', + messageType: legacy + .InAppMessageType.values[(map['messageType'] as num?)?.toInt() ?? 0], ); } diff --git a/lib/types.dart b/lib/types.dart index 9d1f9a32c..5f0882315 100644 --- a/lib/types.dart +++ b/lib/types.dart @@ -1,2184 +1,2113 @@ -import 'enums.dart'; -import 'errors.dart'; -export 'enums.dart'; -export 'errors.dart' - show PurchaseError, PurchaseResult, ConnectionResult, getCurrentPlatform; -export 'types_additions.dart'; - -/// Safely convert any JSON map (possibly Map) -/// into Map, or return null if not a map. -Map? _safeJsonMap(dynamic json) { - if (json == null) return null; - if (json is Map) return json; - if (json is Map) return Map.from(json); - return null; -} - -/// Coerce a JSON value that may be a number or a string into a string. -/// Returns [fallback] if the value is null or an unsupported type. -String _stringFromNumOrString(dynamic value, {String fallback = '0'}) { - if (value == null) return fallback; - if (value is String) return value; - if (value is num) return value.toString(); - return fallback; -} - // ============================================================================ -// CORE TYPES (OpenIAP compliant) +// AUTO-GENERATED TYPES — DO NOT EDIT DIRECTLY +// Run `npm run generate` after updating any *.graphql schema file. // ============================================================================ -/// Change event payload -class ChangeEventPayload { +// ignore_for_file: unused_element, unused_field + +import 'dart:async'; + +// MARK: - Enums + +enum ErrorCode { + Unknown('UNKNOWN'), + UserCancelled('USER_CANCELLED'), + UserError('USER_ERROR'), + ItemUnavailable('ITEM_UNAVAILABLE'), + RemoteError('REMOTE_ERROR'), + NetworkError('NETWORK_ERROR'), + ServiceError('SERVICE_ERROR'), + ReceiptFailed('RECEIPT_FAILED'), + ReceiptFinished('RECEIPT_FINISHED'), + ReceiptFinishedFailed('RECEIPT_FINISHED_FAILED'), + NotPrepared('NOT_PREPARED'), + NotEnded('NOT_ENDED'), + AlreadyOwned('ALREADY_OWNED'), + DeveloperError('DEVELOPER_ERROR'), + BillingResponseJsonParseError('BILLING_RESPONSE_JSON_PARSE_ERROR'), + DeferredPayment('DEFERRED_PAYMENT'), + Interrupted('INTERRUPTED'), + IapNotAvailable('IAP_NOT_AVAILABLE'), + PurchaseError('PURCHASE_ERROR'), + SyncError('SYNC_ERROR'), + TransactionValidationFailed('TRANSACTION_VALIDATION_FAILED'), + ActivityUnavailable('ACTIVITY_UNAVAILABLE'), + AlreadyPrepared('ALREADY_PREPARED'), + Pending('PENDING'), + ConnectionClosed('CONNECTION_CLOSED'), + InitConnection('INIT_CONNECTION'), + ServiceDisconnected('SERVICE_DISCONNECTED'), + QueryProduct('QUERY_PRODUCT'), + SkuNotFound('SKU_NOT_FOUND'), + SkuOfferMismatch('SKU_OFFER_MISMATCH'), + ItemNotOwned('ITEM_NOT_OWNED'), + BillingUnavailable('BILLING_UNAVAILABLE'), + FeatureNotSupported('FEATURE_NOT_SUPPORTED'), + EmptySkuList('EMPTY_SKU_LIST'); + + const ErrorCode(this.value); final String value; - ChangeEventPayload({required this.value}); -} + factory ErrorCode.fromJson(String value) { + switch (value) { + case 'UNKNOWN': + return ErrorCode.Unknown; + case 'Unknown': + return ErrorCode.Unknown; + case 'USER_CANCELLED': + return ErrorCode.UserCancelled; + case 'UserCancelled': + return ErrorCode.UserCancelled; + case 'USER_ERROR': + return ErrorCode.UserError; + case 'UserError': + return ErrorCode.UserError; + case 'ITEM_UNAVAILABLE': + return ErrorCode.ItemUnavailable; + case 'ItemUnavailable': + return ErrorCode.ItemUnavailable; + case 'REMOTE_ERROR': + return ErrorCode.RemoteError; + case 'RemoteError': + return ErrorCode.RemoteError; + case 'NETWORK_ERROR': + return ErrorCode.NetworkError; + case 'NetworkError': + return ErrorCode.NetworkError; + case 'SERVICE_ERROR': + return ErrorCode.ServiceError; + case 'ServiceError': + return ErrorCode.ServiceError; + case 'RECEIPT_FAILED': + return ErrorCode.ReceiptFailed; + case 'ReceiptFailed': + return ErrorCode.ReceiptFailed; + case 'RECEIPT_FINISHED': + return ErrorCode.ReceiptFinished; + case 'ReceiptFinished': + return ErrorCode.ReceiptFinished; + case 'RECEIPT_FINISHED_FAILED': + return ErrorCode.ReceiptFinishedFailed; + case 'ReceiptFinishedFailed': + return ErrorCode.ReceiptFinishedFailed; + case 'NOT_PREPARED': + return ErrorCode.NotPrepared; + case 'NotPrepared': + return ErrorCode.NotPrepared; + case 'NOT_ENDED': + return ErrorCode.NotEnded; + case 'NotEnded': + return ErrorCode.NotEnded; + case 'ALREADY_OWNED': + return ErrorCode.AlreadyOwned; + case 'AlreadyOwned': + return ErrorCode.AlreadyOwned; + case 'DEVELOPER_ERROR': + return ErrorCode.DeveloperError; + case 'DeveloperError': + return ErrorCode.DeveloperError; + case 'BILLING_RESPONSE_JSON_PARSE_ERROR': + return ErrorCode.BillingResponseJsonParseError; + case 'BillingResponseJsonParseError': + return ErrorCode.BillingResponseJsonParseError; + case 'DEFERRED_PAYMENT': + return ErrorCode.DeferredPayment; + case 'DeferredPayment': + return ErrorCode.DeferredPayment; + case 'INTERRUPTED': + return ErrorCode.Interrupted; + case 'Interrupted': + return ErrorCode.Interrupted; + case 'IAP_NOT_AVAILABLE': + return ErrorCode.IapNotAvailable; + case 'IapNotAvailable': + return ErrorCode.IapNotAvailable; + case 'PURCHASE_ERROR': + return ErrorCode.PurchaseError; + case 'PurchaseError': + return ErrorCode.PurchaseError; + case 'SYNC_ERROR': + return ErrorCode.SyncError; + case 'SyncError': + return ErrorCode.SyncError; + case 'TRANSACTION_VALIDATION_FAILED': + return ErrorCode.TransactionValidationFailed; + case 'TransactionValidationFailed': + return ErrorCode.TransactionValidationFailed; + case 'ACTIVITY_UNAVAILABLE': + return ErrorCode.ActivityUnavailable; + case 'ActivityUnavailable': + return ErrorCode.ActivityUnavailable; + case 'ALREADY_PREPARED': + return ErrorCode.AlreadyPrepared; + case 'AlreadyPrepared': + return ErrorCode.AlreadyPrepared; + case 'PENDING': + return ErrorCode.Pending; + case 'Pending': + return ErrorCode.Pending; + case 'CONNECTION_CLOSED': + return ErrorCode.ConnectionClosed; + case 'ConnectionClosed': + return ErrorCode.ConnectionClosed; + case 'INIT_CONNECTION': + return ErrorCode.InitConnection; + case 'InitConnection': + return ErrorCode.InitConnection; + case 'SERVICE_DISCONNECTED': + return ErrorCode.ServiceDisconnected; + case 'ServiceDisconnected': + return ErrorCode.ServiceDisconnected; + case 'QUERY_PRODUCT': + return ErrorCode.QueryProduct; + case 'QueryProduct': + return ErrorCode.QueryProduct; + case 'SKU_NOT_FOUND': + return ErrorCode.SkuNotFound; + case 'SkuNotFound': + return ErrorCode.SkuNotFound; + case 'SKU_OFFER_MISMATCH': + return ErrorCode.SkuOfferMismatch; + case 'SkuOfferMismatch': + return ErrorCode.SkuOfferMismatch; + case 'ITEM_NOT_OWNED': + return ErrorCode.ItemNotOwned; + case 'ItemNotOwned': + return ErrorCode.ItemNotOwned; + case 'BILLING_UNAVAILABLE': + return ErrorCode.BillingUnavailable; + case 'BillingUnavailable': + return ErrorCode.BillingUnavailable; + case 'FEATURE_NOT_SUPPORTED': + return ErrorCode.FeatureNotSupported; + case 'FeatureNotSupported': + return ErrorCode.FeatureNotSupported; + case 'EMPTY_SKU_LIST': + return ErrorCode.EmptySkuList; + case 'EmptySkuList': + return ErrorCode.EmptySkuList; + } + throw ArgumentError('Unknown ErrorCode value: $value'); + } -/// Product type enum (OpenIAP compliant) -/// 'inapp' for consumables/non-consumables, 'subs' for subscriptions -class ProductType { - static const String inapp = 'inapp'; - static const String subs = 'subs'; + String toJson() => value; } -// ============================================================================ -// PURCHASE OPTIONS (OpenIAP compliant) -// ============================================================================ +enum IapEvent { + PurchaseUpdated('PURCHASE_UPDATED'), + PurchaseError('PURCHASE_ERROR'), + PromotedProductIOS('PROMOTED_PRODUCT_IOS'); -/// Options for getAvailablePurchases method (OpenIAP compliant) -class PurchaseOptions { - /// iOS only: Whether to also publish to event listener - final bool? alsoPublishToEventListenerIOS; + const IapEvent(this.value); + final String value; - /// iOS only: Whether to only include active items (default: true) - /// Set to false to include expired subscriptions - final bool? onlyIncludeActiveItemsIOS; + factory IapEvent.fromJson(String value) { + switch (value) { + case 'PURCHASE_UPDATED': + return IapEvent.PurchaseUpdated; + case 'PurchaseUpdated': + return IapEvent.PurchaseUpdated; + case 'PURCHASE_ERROR': + return IapEvent.PurchaseError; + case 'PurchaseError': + return IapEvent.PurchaseError; + case 'PROMOTED_PRODUCT_IOS': + return IapEvent.PromotedProductIOS; + case 'PromotedProductIOS': + return IapEvent.PromotedProductIOS; + } + throw ArgumentError('Unknown IapEvent value: $value'); + } - const PurchaseOptions({ - this.alsoPublishToEventListenerIOS, - this.onlyIncludeActiveItemsIOS, - }); + String toJson() => value; +} - Map toMap() { - return { - if (alsoPublishToEventListenerIOS != null) - 'alsoPublishToEventListenerIOS': alsoPublishToEventListenerIOS, - if (onlyIncludeActiveItemsIOS != null) - 'onlyIncludeActiveItemsIOS': onlyIncludeActiveItemsIOS, - }; +enum IapPlatform { + IOS('IOS'), + Android('ANDROID'); + + const IapPlatform(this.value); + final String value; + + factory IapPlatform.fromJson(String value) { + switch (value) { + case 'IOS': + return IapPlatform.IOS; + case 'ANDROID': + return IapPlatform.Android; + case 'Android': + return IapPlatform.Android; + } + throw ArgumentError('Unknown IapPlatform value: $value'); } + + String toJson() => value; } -// ============================================================================ -// COMMON TYPES (Base types shared across all platforms - OpenIAP compliant) -// ============================================================================ +enum PaymentModeIOS { + Empty('EMPTY'), + FreeTrial('FREE_TRIAL'), + PayAsYouGo('PAY_AS_YOU_GO'), + PayUpFront('PAY_UP_FRONT'); -/// Base purchase class (OpenIAP compliant) -class PurchaseCommon { - final String id; // Transaction identifier - used by finishTransaction - final String productId; // Product identifier - which product was purchased - final List? - ids; // Product identifiers for purchases that include multiple products - @Deprecated('Use id instead') - final String? transactionId; // @deprecated - use id instead - final int transactionDate; - final String transactionReceipt; - final String? - purchaseToken; // Unified purchase token (jwsRepresentation for iOS, purchaseToken for Android) - final String? platform; - - PurchaseCommon({ - required this.id, - required this.productId, - required this.transactionDate, - required this.transactionReceipt, - this.ids, - @Deprecated('Use id instead') this.transactionId, - this.purchaseToken, - this.platform, - }); -} + const PaymentModeIOS(this.value); + final String value; -// ============================================================================ -// IOS TYPES (OpenIAP compliant) -// ============================================================================ + factory PaymentModeIOS.fromJson(String value) { + switch (value) { + case 'EMPTY': + return PaymentModeIOS.Empty; + case 'Empty': + return PaymentModeIOS.Empty; + case 'FREE_TRIAL': + return PaymentModeIOS.FreeTrial; + case 'FreeTrial': + return PaymentModeIOS.FreeTrial; + case 'PAY_AS_YOU_GO': + return PaymentModeIOS.PayAsYouGo; + case 'PayAsYouGo': + return PaymentModeIOS.PayAsYouGo; + case 'PAY_UP_FRONT': + return PaymentModeIOS.PayUpFront; + case 'PayUpFront': + return PaymentModeIOS.PayUpFront; + } + throw ArgumentError('Unknown PaymentModeIOS value: $value'); + } -/// iOS subscription period units -class SubscriptionIosPeriod { - static const String DAY = 'DAY'; - static const String WEEK = 'WEEK'; - static const String MONTH = 'MONTH'; - static const String YEAR = 'YEAR'; - static const String empty = ''; + String toJson() => value; } -/// iOS payment mode -class PaymentModeIOS { - static const String empty = ''; - static const String FREETRIAL = 'FREETRIAL'; - static const String PAYASYOUGO = 'PAYASYOUGO'; - static const String PAYUPFRONT = 'PAYUPFRONT'; -} +enum ProductQueryType { + InApp('IN_APP'), + Subs('SUBS'), + All('ALL'); -/// Android purchase state enum (OpenIAP compliant) -class PurchaseAndroidState { - static const int UNSPECIFIED_STATE = 0; - static const int PURCHASED = 1; - static const int PENDING = 2; + const ProductQueryType(this.value); + final String value; + + factory ProductQueryType.fromJson(String value) { + switch (value) { + case 'IN_APP': + return ProductQueryType.InApp; + case 'InApp': + return ProductQueryType.InApp; + case 'SUBS': + return ProductQueryType.Subs; + case 'Subs': + return ProductQueryType.Subs; + case 'ALL': + return ProductQueryType.All; + case 'All': + return ProductQueryType.All; + } + throw ArgumentError('Unknown ProductQueryType value: $value'); + } + + String toJson() => value; } -/// iOS subscription offer (OpenIAP compliant) -class SubscriptionOfferIOS { - final String displayPrice; - final String id; - final String paymentMode; - final Map period; - final int periodCount; - final double price; - final String type; // 'introductory' | 'promotional' +enum ProductType { + InApp('IN_APP'), + Subs('SUBS'); - SubscriptionOfferIOS({ - required this.displayPrice, - required this.id, - required this.paymentMode, - required this.period, - required this.periodCount, - required this.price, - required this.type, - }); + const ProductType(this.value); + final String value; - factory SubscriptionOfferIOS.fromJson(Map json) { - return SubscriptionOfferIOS( - displayPrice: json['displayPrice'] as String? ?? '', - id: json['id'] as String? ?? '', - paymentMode: json['paymentMode'] as String? ?? '', - period: json['period'] != null - ? Map.from(json['period'] as Map) - : {'unit': '', 'value': 0}, - periodCount: json['periodCount'] as int? ?? 0, - price: (json['price'] as num?)?.toDouble() ?? 0.0, - type: json['type'] as String? ?? '', - ); + factory ProductType.fromJson(String value) { + switch (value) { + case 'IN_APP': + return ProductType.InApp; + case 'InApp': + return ProductType.InApp; + case 'SUBS': + return ProductType.Subs; + case 'Subs': + return ProductType.Subs; + } + throw ArgumentError('Unknown ProductType value: $value'); } + + String toJson() => value; } -/// ProductCommon - Base product interface (renamed from BaseProduct for OpenIAP spec alignment) -abstract class ProductCommon { - // OpenIAP core fields - final String id; - final String? title; - final String? description; - final String type; - final String? displayName; - final String displayPrice; - final String? currency; - final double? price; - final String? debugDescription; - final String? platform; - final String? localizedPrice; - final IapPlatform platformEnum; +enum ProductTypeIOS { + Consumable('CONSUMABLE'), + NonConsumable('NON_CONSUMABLE'), + AutoRenewableSubscription('AUTO_RENEWABLE_SUBSCRIPTION'), + NonRenewingSubscription('NON_RENEWING_SUBSCRIPTION'); - ProductCommon({ - required this.id, - required this.type, - required this.displayPrice, - required this.platformEnum, - this.title, - this.description, - this.displayName, - this.currency, - this.price, - this.debugDescription, - this.platform, - this.localizedPrice, - }); + const ProductTypeIOS(this.value); + final String value; + + factory ProductTypeIOS.fromJson(String value) { + switch (value) { + case 'CONSUMABLE': + return ProductTypeIOS.Consumable; + case 'Consumable': + return ProductTypeIOS.Consumable; + case 'NON_CONSUMABLE': + return ProductTypeIOS.NonConsumable; + case 'NonConsumable': + return ProductTypeIOS.NonConsumable; + case 'AUTO_RENEWABLE_SUBSCRIPTION': + return ProductTypeIOS.AutoRenewableSubscription; + case 'AutoRenewableSubscription': + return ProductTypeIOS.AutoRenewableSubscription; + case 'NON_RENEWING_SUBSCRIPTION': + return ProductTypeIOS.NonRenewingSubscription; + case 'NonRenewingSubscription': + return ProductTypeIOS.NonRenewingSubscription; + } + throw ArgumentError('Unknown ProductTypeIOS value: $value'); + } + + String toJson() => value; } -/// Product class for non-subscription items (OpenIAP compliant) -class Product extends ProductCommon { - // iOS-specific fields per OpenIAP spec - final List? discountsIOS; - final SubscriptionInfo? subscription; - final String? introductoryPriceNumberOfPeriodsIOS; - final String? introductoryPriceSubscriptionPeriodIOS; - final String? subscriptionGroupIdIOS; - final String? subscriptionPeriodUnitIOS; - final String? subscriptionPeriodNumberIOS; - final String? introductoryPricePaymentModeIOS; - final String? environmentIOS; // "Sandbox" | "Production" - final List? promotionalOfferIdsIOS; - - // Android-specific fields per OpenIAP spec - final String? nameAndroid; - final Map? oneTimePurchaseOfferDetailsAndroid; - final String? originalPrice; - final double? originalPriceAmount; - final String? freeTrialPeriod; - final String? iconUrl; - final List? subscriptionOfferDetailsAndroid; - final String? subscriptionPeriodAndroid; - final String? introductoryPriceCyclesAndroid; - final String? introductoryPricePeriodAndroid; - final String? freeTrialPeriodAndroid; - final String? signatureAndroid; - final List? subscriptionOffersAndroid; - - Product({ - // OpenIAP fields (primary) - String? id, - super.title, - super.description, - String? type, - super.displayName, - String? displayPrice, - super.currency, - double? price, - super.debugDescription, - String? platform, - String? priceString, - super.localizedPrice, - IapPlatform? platformEnum, - // iOS fields per OpenIAP spec - this.discountsIOS, - this.subscription, - this.introductoryPriceNumberOfPeriodsIOS, - this.introductoryPriceSubscriptionPeriodIOS, - this.subscriptionGroupIdIOS, - this.subscriptionPeriodUnitIOS, - this.subscriptionPeriodNumberIOS, - this.introductoryPricePaymentModeIOS, - this.environmentIOS, - this.promotionalOfferIdsIOS, - // Android fields per OpenIAP spec - this.nameAndroid, - this.oneTimePurchaseOfferDetailsAndroid, - this.originalPrice, - this.originalPriceAmount, - this.freeTrialPeriod, - this.iconUrl, - this.subscriptionOfferDetailsAndroid, - this.subscriptionPeriodAndroid, - this.introductoryPriceCyclesAndroid, - this.introductoryPricePeriodAndroid, - this.freeTrialPeriodAndroid, - this.signatureAndroid, - this.subscriptionOffersAndroid, - }) : super( - id: id ?? '', - type: type ?? 'inapp', - displayPrice: displayPrice ?? localizedPrice ?? '0', - platformEnum: platformEnum ?? IapPlatform.ios, - price: price ?? - (priceString != null ? double.tryParse(priceString) : null), - platform: - platform ?? (platformEnum == IapPlatform.ios ? 'ios' : 'android'), - ); +enum PurchaseState { + Pending('PENDING'), + Purchased('PURCHASED'), + Failed('FAILED'), + Restored('RESTORED'), + Deferred('DEFERRED'), + Unknown('UNKNOWN'); - factory Product.fromJson(Map json) { - return Product( - // Use OpenIAP fields primarily, fallback to legacy - id: json['id'] as String? ?? json['productId'] as String? ?? '', - title: json['title'] as String? ?? '', - description: json['description'] as String? ?? '', - type: json['type'] as String? ?? 'inapp', - displayName: json['displayName'] as String?, - displayPrice: json['displayPrice'] as String? ?? - json['localizedPrice'] as String? ?? - '0', - currency: json['currency'] as String? ?? '', - price: (json['price'] is num) - ? (json['price'] as num).toDouble() - : (json['price'] is String - ? double.tryParse(json['price'] as String) - : null), - platform: json['platform'] as String?, - priceString: (json['price'] is String) ? json['price'] as String : null, - localizedPrice: json['localizedPrice'] as String?, - platformEnum: - json['platform'] == 'android' ? IapPlatform.android : IapPlatform.ios, - discountsIOS: json['discountsIOS'] != null - ? (json['discountsIOS'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? DiscountIOS.fromJson(map) : null; - }) - .whereType() - .toList() - : null, - subscription: json['subscription'] != null - ? SubscriptionInfo.fromJson( - Map.from(json['subscription'] as Map), - ) - : null, - introductoryPriceNumberOfPeriodsIOS: - json['introductoryPriceNumberOfPeriodsIOS'] as String?, - introductoryPriceSubscriptionPeriodIOS: - json['introductoryPriceSubscriptionPeriodIOS'] as String?, - subscriptionGroupIdIOS: json['subscriptionGroupIdIOS'] as String?, - subscriptionPeriodUnitIOS: json['subscriptionPeriodUnitIOS'] as String?, - subscriptionPeriodNumberIOS: - json['subscriptionPeriodNumberIOS'] as String?, - introductoryPricePaymentModeIOS: - json['introductoryPricePaymentModeIOS'] as String?, - environmentIOS: json['environmentIOS'] as String?, - promotionalOfferIdsIOS: json['promotionalOfferIdsIOS'] != null - ? (json['promotionalOfferIdsIOS'] as List).cast() - : null, - // Android fields per OpenIAP spec - nameAndroid: json['nameAndroid'] as String?, - oneTimePurchaseOfferDetailsAndroid: - json['oneTimePurchaseOfferDetailsAndroid'] != null - ? Map.from( - json['oneTimePurchaseOfferDetailsAndroid'] as Map, - ) - : null, - originalPrice: json['originalPrice'] as String?, - originalPriceAmount: (json['originalPriceAmount'] as num?)?.toDouble(), - freeTrialPeriod: json['freeTrialPeriod'] as String?, - iconUrl: json['iconUrl'] as String?, - // Use new Android suffix field if available, fallback to old field for compatibility - subscriptionOfferDetailsAndroid: - json['subscriptionOfferDetailsAndroid'] != null - ? (json['subscriptionOfferDetailsAndroid'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? OfferDetail.fromJson(map) : null; - }) - .whereType() - .toList() - : null, - subscriptionPeriodAndroid: json['subscriptionPeriodAndroid'] as String?, - introductoryPriceCyclesAndroid: - json['introductoryPriceCyclesAndroid'] as String?, - introductoryPricePeriodAndroid: - json['introductoryPricePeriodAndroid'] as String?, - freeTrialPeriodAndroid: json['freeTrialPeriodAndroid'] as String?, - signatureAndroid: json['signatureAndroid'] as String?, - subscriptionOffersAndroid: json['subscriptionOffersAndroid'] != null - ? (json['subscriptionOffersAndroid'] as List) - .map( - (o) => SubscriptionOfferAndroid.fromJson( - _safeJsonMap(o) ?? {}, - ), - ) - .toList() - : null, - ); - } + const PurchaseState(this.value); + final String value; - @override - String toString() { - final buffer = StringBuffer('$runtimeType{\n'); - buffer.writeln(' id: $id,'); - buffer.writeln(' price: $price,'); - buffer.writeln(' currency: $currency,'); - buffer.writeln(' localizedPrice: $localizedPrice,'); - buffer.writeln(' title: $title,'); - if (description != null) { - final desc = description!; - final short = desc.length > 100 ? '${desc.substring(0, 100)}...' : desc; - buffer.writeln(' description: $short,'); - } else { - buffer.writeln(' description: null,'); + factory PurchaseState.fromJson(String value) { + switch (value) { + case 'PENDING': + return PurchaseState.Pending; + case 'Pending': + return PurchaseState.Pending; + case 'PURCHASED': + return PurchaseState.Purchased; + case 'Purchased': + return PurchaseState.Purchased; + case 'FAILED': + return PurchaseState.Failed; + case 'Failed': + return PurchaseState.Failed; + case 'RESTORED': + return PurchaseState.Restored; + case 'Restored': + return PurchaseState.Restored; + case 'DEFERRED': + return PurchaseState.Deferred; + case 'Deferred': + return PurchaseState.Deferred; + case 'UNKNOWN': + return PurchaseState.Unknown; + case 'Unknown': + return PurchaseState.Unknown; } - buffer.writeln(' type: $type,'); - buffer.writeln(' platform: $platform,'); + throw ArgumentError('Unknown PurchaseState value: $value'); + } - // iOS specific fields (only print non-null) - if (displayName != null) buffer.writeln(' displayName: $displayName,'); - if (environmentIOS != null) { - buffer.writeln(' environmentIOS: $environmentIOS,'); - } - if (subscriptionPeriodUnitIOS != null) { - buffer.writeln( - ' subscriptionPeriodUnitIOS: $subscriptionPeriodUnitIOS,', - ); - } - if (subscriptionPeriodNumberIOS != null) { - buffer.writeln( - ' subscriptionPeriodNumberIOS: $subscriptionPeriodNumberIOS,', - ); - } - if (discountsIOS != null && discountsIOS!.isNotEmpty) { - buffer.writeln(' discountsIOS: ${discountsIOS!.length} discount(s),'); - } + String toJson() => value; +} - // Android specific fields (show even if null for Android platform) - if (platform == 'android') { - buffer.writeln( - ' nameAndroid: ${nameAndroid != null ? '"$nameAndroid"' : 'null'},', - ); - buffer.writeln( - ' oneTimePurchaseOfferDetailsAndroid: $oneTimePurchaseOfferDetailsAndroid,', - ); - } else { - if (nameAndroid != null) buffer.writeln(' nameAndroid: "$nameAndroid",'); - if (oneTimePurchaseOfferDetailsAndroid != null) { - buffer.writeln( - ' oneTimePurchaseOfferDetailsAndroid: $oneTimePurchaseOfferDetailsAndroid,', - ); - } - } - if (originalPrice != null) { - buffer.writeln(' originalPrice: $originalPrice,'); - } - if (freeTrialPeriod != null) { - buffer.writeln(' freeTrialPeriod: $freeTrialPeriod,'); - } - if (subscriptionPeriodAndroid != null) { - buffer.writeln( - ' subscriptionPeriodAndroid: $subscriptionPeriodAndroid,', - ); - } - if (subscriptionOfferDetailsAndroid != null && - subscriptionOfferDetailsAndroid!.isNotEmpty) { - buffer.writeln( - ' subscriptionOfferDetailsAndroid: ${subscriptionOfferDetailsAndroid!.length} offer(s),', - ); - } +enum SubscriptionOfferTypeIOS { + Introductory('INTRODUCTORY'), + Promotional('PROMOTIONAL'); - // For Subscription class, add subscription info - if (this is ProductSubscription) { - final sub = this as ProductSubscription; - if (sub.subscription != null) { - buffer.writeln(' subscription: ${sub.subscription},'); - } - if (sub.subscriptionGroupIdIOS != null) { - buffer.writeln( - ' subscriptionGroupIdIOS: ${sub.subscriptionGroupIdIOS},', - ); - } - if (sub.subscriptionOffersAndroid != null && - sub.subscriptionOffersAndroid!.isNotEmpty) { - buffer.writeln( - ' subscriptionOffersAndroid: ${sub.subscriptionOffersAndroid!.length} offer(s),', - ); - } - } + const SubscriptionOfferTypeIOS(this.value); + final String value; - // Remove last comma and close - final str = buffer.toString(); - if (str.endsWith(',\n')) { - return '${str.substring(0, str.length - 2)}\n}'; - } - return '$str}'; - } - - /// Convert iOS native product types to OpenIAP standard types - String _convertTypeForOpenIAP(String type, bool isIOS) { - if (!isIOS) return type; // Android types are already correct - - switch (type.toLowerCase()) { - case 'consumable': - case 'nonconsumable': - case 'nonrenewable': - return 'inapp'; - case 'autorenewable': - return 'subs'; - default: - return type; // Return as-is if not recognized + factory SubscriptionOfferTypeIOS.fromJson(String value) { + switch (value) { + case 'INTRODUCTORY': + return SubscriptionOfferTypeIOS.Introductory; + case 'Introductory': + return SubscriptionOfferTypeIOS.Introductory; + case 'PROMOTIONAL': + return SubscriptionOfferTypeIOS.Promotional; + case 'Promotional': + return SubscriptionOfferTypeIOS.Promotional; } + throw ArgumentError('Unknown SubscriptionOfferTypeIOS value: $value'); } - Map toJson() { - // Determine if this is iOS or Android - final isIOS = platformEnum == IapPlatform.ios; + String toJson() => value; +} - final json = { - 'id': id, - 'title': title ?? '', - 'description': description ?? '', - 'type': _convertTypeForOpenIAP(type, isIOS), - 'currency': currency ?? '', - 'platform': isIOS ? 'ios' : 'android', // Use string literal - }; +enum SubscriptionPeriodIOS { + Day('DAY'), + Week('WEEK'), + Month('MONTH'), + Year('YEAR'), + Empty('EMPTY'); - // Price field (as number for iOS type compatibility) - if (price != null) { - json['price'] = price; - } + const SubscriptionPeriodIOS(this.value); + final String value; - // displayPrice field (required for iOS) - json['displayPrice'] = displayPrice; - if (localizedPrice != null && displayPrice != localizedPrice) { - json['localizedPrice'] = localizedPrice; // Include if different + factory SubscriptionPeriodIOS.fromJson(String value) { + switch (value) { + case 'DAY': + return SubscriptionPeriodIOS.Day; + case 'Day': + return SubscriptionPeriodIOS.Day; + case 'WEEK': + return SubscriptionPeriodIOS.Week; + case 'Week': + return SubscriptionPeriodIOS.Week; + case 'MONTH': + return SubscriptionPeriodIOS.Month; + case 'Month': + return SubscriptionPeriodIOS.Month; + case 'YEAR': + return SubscriptionPeriodIOS.Year; + case 'Year': + return SubscriptionPeriodIOS.Year; + case 'EMPTY': + return SubscriptionPeriodIOS.Empty; + case 'Empty': + return SubscriptionPeriodIOS.Empty; } + throw ArgumentError('Unknown SubscriptionPeriodIOS value: $value'); + } - // Optional displayName field - if (displayName != null) { - json['displayName'] = displayName; - } + String toJson() => value; +} - // iOS specific fields with correct naming - if (isIOS) { - if (displayName != null) { - json['displayNameIOS'] = displayName; - } - // Add OpenIAP compliant iOS fields for ProductIOS - if (this is ProductIOS) { - final productIOS = this as ProductIOS; - if (productIOS.isFamilyShareableIOS != null) { - json['isFamilyShareableIOS'] = productIOS.isFamilyShareableIOS; - } - if (productIOS.jsonRepresentationIOS != null) { - json['jsonRepresentationIOS'] = productIOS.jsonRepresentationIOS; - } - } - // Add OpenIAP compliant iOS fields for Subscription - // Note: In Product class, this check is needed; in Subscription class, it's redundant - else if (this is ProductSubscription) { - // Remove unnecessary cast since we know the type - if ((this as dynamic).isFamilyShareableIOS != null) { - json['isFamilyShareableIOS'] = (this as dynamic).isFamilyShareableIOS; - } - if ((this as dynamic).jsonRepresentationIOS != null) { - json['jsonRepresentationIOS'] = - (this as dynamic).jsonRepresentationIOS; - } - } - if (environmentIOS != null) { - json['environmentIOS'] = environmentIOS; - } - if (subscriptionGroupIdIOS != null) { - json['subscriptionGroupIdIOS'] = subscriptionGroupIdIOS; - } - if (promotionalOfferIdsIOS != null && - promotionalOfferIdsIOS!.isNotEmpty) { - json['promotionalOfferIdsIOS'] = promotionalOfferIdsIOS; - } - if (discountsIOS != null && discountsIOS!.isNotEmpty) { - json['discountsIOS'] = discountsIOS!.map((d) => d.toJson()).toList(); - } - // Add subscriptionInfoIOS with proper structure for OpenIAP - final subscriptionInfoJson = {}; +// MARK: - Interfaces - // Add subscriptionGroupId (convert to string for OpenIAP) - if (subscriptionGroupIdIOS != null) { - subscriptionInfoJson['subscriptionGroupId'] = - subscriptionGroupIdIOS.toString(); - } +abstract class ProductCommon { + String get currency; + String? get debugDescription; + String get description; + String? get displayName; + String get displayPrice; + String get id; + IapPlatform get platform; + double? get price; + String get title; + ProductType get type; +} - // Add subscriptionPeriod with proper structure - if (subscriptionPeriodUnitIOS != null && - subscriptionPeriodNumberIOS != null) { - subscriptionInfoJson['subscriptionPeriod'] = { - 'unit': subscriptionPeriodUnitIOS, - 'value': int.tryParse(subscriptionPeriodNumberIOS!) ?? 1, - }; - } +abstract class PurchaseCommon { + String get id; + List? get ids; + bool get isAutoRenewing; + IapPlatform get platform; + String get productId; + PurchaseState get purchaseState; + + /// Unified purchase token (iOS JWS, Android purchaseToken) + String? get purchaseToken; + int get quantity; + double get transactionDate; +} - // Merge existing subscription info if available - if (subscription != null) { - subscriptionInfoJson.addAll(subscription!.toJson()); - } +// MARK: - Objects - if (subscriptionInfoJson.isNotEmpty) { - json['subscriptionInfoIOS'] = subscriptionInfoJson; - } +class ActiveSubscription { + const ActiveSubscription({ + this.autoRenewingAndroid, + this.daysUntilExpirationIOS, + this.environmentIOS, + this.expirationDateIOS, + required this.isActive, + required this.productId, + this.purchaseToken, + required this.transactionDate, + required this.transactionId, + this.willExpireSoon, + }); - // Keep these fields for backward compatibility - if (subscriptionPeriodNumberIOS != null) { - json['subscriptionPeriodNumberIOS'] = subscriptionPeriodNumberIOS; - } - if (subscriptionPeriodUnitIOS != null) { - json['subscriptionPeriodUnitIOS'] = subscriptionPeriodUnitIOS; - } - if (introductoryPriceNumberOfPeriodsIOS != null) { - json['introductoryPriceNumberOfPeriodsIOS'] = - introductoryPriceNumberOfPeriodsIOS; - } - if (introductoryPriceSubscriptionPeriodIOS != null) { - json['introductoryPriceSubscriptionPeriodIOS'] = - introductoryPriceSubscriptionPeriodIOS; - } - if (introductoryPricePaymentModeIOS != null) { - json['introductoryPricePaymentModeIOS'] = - introductoryPricePaymentModeIOS; - } - } + final bool? autoRenewingAndroid; + final double? daysUntilExpirationIOS; + final String? environmentIOS; + final double? expirationDateIOS; + final bool isActive; + final String productId; + final String? purchaseToken; + final double transactionDate; + final String transactionId; + final bool? willExpireSoon; - // Android specific fields - if (!isIOS) { - if (nameAndroid != null) json['nameAndroid'] = nameAndroid; - if (oneTimePurchaseOfferDetailsAndroid != null) { - json['oneTimePurchaseOfferDetailsAndroid'] = - oneTimePurchaseOfferDetailsAndroid; - } - if (originalPrice != null) json['originalPrice'] = originalPrice; - if (originalPriceAmount != null) { - json['originalPriceAmount'] = originalPriceAmount; - } - if (freeTrialPeriod != null) json['freeTrialPeriod'] = freeTrialPeriod; - if (iconUrl != null) json['iconUrl'] = iconUrl; - // TODO(v6.4.0): Show subscription offer fields only on Android platform - // Always show Android suffix field (TypeScript compatible) - if (subscriptionOfferDetailsAndroid != null && - subscriptionOfferDetailsAndroid!.isNotEmpty) { - json['subscriptionOfferDetailsAndroid'] = - subscriptionOfferDetailsAndroid!.map((o) => o.toJson()).toList(); - } - if (subscriptionOffersAndroid != null && - subscriptionOffersAndroid!.isNotEmpty) { - json['subscriptionOffersAndroid'] = - subscriptionOffersAndroid!.map((o) => o.toJson()).toList(); - } - } + factory ActiveSubscription.fromJson(Map json) { + return ActiveSubscription( + autoRenewingAndroid: json['autoRenewingAndroid'] as bool?, + daysUntilExpirationIOS: + (json['daysUntilExpirationIOS'] as num?)?.toDouble(), + environmentIOS: json['environmentIOS'] as String?, + expirationDateIOS: (json['expirationDateIOS'] as num?)?.toDouble(), + isActive: json['isActive'] as bool, + productId: json['productId'] as String, + purchaseToken: json['purchaseToken'] as String?, + transactionDate: (json['transactionDate'] as num).toDouble(), + transactionId: json['transactionId'] as String, + willExpireSoon: json['willExpireSoon'] as bool?, + ); + } - return json; + Map toJson() { + return { + '__typename': 'ActiveSubscription', + 'autoRenewingAndroid': autoRenewingAndroid, + 'daysUntilExpirationIOS': daysUntilExpirationIOS, + 'environmentIOS': environmentIOS, + 'expirationDateIOS': expirationDateIOS, + 'isActive': isActive, + 'productId': productId, + 'purchaseToken': purchaseToken, + 'transactionDate': transactionDate, + 'transactionId': transactionId, + 'willExpireSoon': willExpireSoon, + }; } } -/// iOS-specific discount information -/// iOS discount (OpenIAP name: Discount) -class DiscountIOS { - final String identifier; - final String type; - final String numberOfPeriods; // Changed to String to match OpenIAP - final String price; - final String localizedPrice; - final String paymentMode; - final String subscriptionPeriod; +class AppTransaction { + const AppTransaction({ + required this.appId, + this.appTransactionId, + required this.appVersion, + required this.appVersionId, + required this.bundleId, + required this.deviceVerification, + required this.deviceVerificationNonce, + required this.environment, + required this.originalAppVersion, + this.originalPlatform, + required this.originalPurchaseDate, + this.preorderDate, + required this.signedDate, + }); + + final double appId; + final String? appTransactionId; + final String appVersion; + final double appVersionId; + final String bundleId; + final String deviceVerification; + final String deviceVerificationNonce; + final String environment; + final String originalAppVersion; + final String? originalPlatform; + final double originalPurchaseDate; + final double? preorderDate; + final double signedDate; + + factory AppTransaction.fromJson(Map json) { + return AppTransaction( + appId: (json['appId'] as num).toDouble(), + appTransactionId: json['appTransactionId'] as String?, + appVersion: json['appVersion'] as String, + appVersionId: (json['appVersionId'] as num).toDouble(), + bundleId: json['bundleId'] as String, + deviceVerification: json['deviceVerification'] as String, + deviceVerificationNonce: json['deviceVerificationNonce'] as String, + environment: json['environment'] as String, + originalAppVersion: json['originalAppVersion'] as String, + originalPlatform: json['originalPlatform'] as String?, + originalPurchaseDate: (json['originalPurchaseDate'] as num).toDouble(), + preorderDate: (json['preorderDate'] as num?)?.toDouble(), + signedDate: (json['signedDate'] as num).toDouble(), + ); + } - DiscountIOS({ + Map toJson() { + return { + '__typename': 'AppTransaction', + 'appId': appId, + 'appTransactionId': appTransactionId, + 'appVersion': appVersion, + 'appVersionId': appVersionId, + 'bundleId': bundleId, + 'deviceVerification': deviceVerification, + 'deviceVerificationNonce': deviceVerificationNonce, + 'environment': environment, + 'originalAppVersion': originalAppVersion, + 'originalPlatform': originalPlatform, + 'originalPurchaseDate': originalPurchaseDate, + 'preorderDate': preorderDate, + 'signedDate': signedDate, + }; + } +} + +class DiscountIOS { + const DiscountIOS({ required this.identifier, - required this.type, - required this.price, - required this.localizedPrice, - required this.paymentMode, + this.localizedPrice, required this.numberOfPeriods, + required this.paymentMode, + required this.price, + required this.priceAmount, required this.subscriptionPeriod, + required this.type, }); + final String identifier; + final String? localizedPrice; + final int numberOfPeriods; + final PaymentModeIOS paymentMode; + final String price; + final double priceAmount; + final String subscriptionPeriod; + final String type; + factory DiscountIOS.fromJson(Map json) { return DiscountIOS( - identifier: json['identifier'] as String? ?? '', - type: json['type'] as String? ?? '', - price: _stringFromNumOrString(json['price']), - localizedPrice: json['localizedPrice'] as String? ?? '', - paymentMode: json['paymentMode'] as String? ?? '', - numberOfPeriods: json['numberOfPeriods']?.toString() ?? '0', - subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', + identifier: json['identifier'] as String, + localizedPrice: json['localizedPrice'] as String?, + numberOfPeriods: json['numberOfPeriods'] as int, + paymentMode: PaymentModeIOS.fromJson(json['paymentMode'] as String), + price: json['price'] as String, + priceAmount: (json['priceAmount'] as num).toDouble(), + subscriptionPeriod: json['subscriptionPeriod'] as String, + type: json['type'] as String, ); } Map toJson() { return { + '__typename': 'DiscountIOS', 'identifier': identifier, - 'type': type, - 'price': price, 'localizedPrice': localizedPrice, - 'paymentMode': paymentMode, 'numberOfPeriods': numberOfPeriods, + 'paymentMode': paymentMode.toJson(), + 'price': price, + 'priceAmount': priceAmount, 'subscriptionPeriod': subscriptionPeriod, + 'type': type, }; } - - @Deprecated('[2.0.0] Use DiscountIOS.fromJson instead') - DiscountIOS.fromJSON(Map json) - : identifier = json['identifier'] as String, - type = json['type'] as String, - price = json['price'] as String, - localizedPrice = json['localizedPrice'] as String, - paymentMode = json['paymentMode'] as String, - numberOfPeriods = json['numberOfPeriods']?.toString() ?? '0', - subscriptionPeriod = json['subscriptionPeriod'] as String; } -class ProductSubscription extends ProductCommon { - /// OpenIAP compatibility: ids array containing the productId - List get ids => [id]; +class DiscountOfferIOS { + const DiscountOfferIOS({ + /// Discount identifier + required this.identifier, - // iOS fields per OpenIAP spec - // displayName and displayPrice are inherited from ProductCommon - final bool? isFamilyShareableIOS; - final String? jsonRepresentationIOS; - final List? discountsIOS; - final SubscriptionInfo? subscription; - final String? introductoryPriceNumberOfPeriodsIOS; - final String? introductoryPriceSubscriptionPeriodIOS; - final String? subscriptionGroupIdIOS; - final String? subscriptionPeriodUnitIOS; - final String? subscriptionPeriodNumberIOS; - final String? introductoryPricePaymentModeIOS; - final String? environmentIOS; // "Sandbox" | "Production" - final List? promotionalOfferIdsIOS; - - // Android fields per OpenIAP spec - final String? nameAndroid; - final Map? oneTimePurchaseOfferDetailsAndroid; - final String? originalPrice; - final double? originalPriceAmount; - final String? freeTrialPeriod; - final String? iconUrl; - final List? subscriptionOfferDetailsAndroid; - final String? subscriptionPeriodAndroid; - final String? introductoryPriceCyclesAndroid; - final String? introductoryPricePeriodAndroid; - final String? freeTrialPeriodAndroid; - final String? signatureAndroid; - final List? subscriptionOffersAndroid; - - ProductSubscription({ - required String price, - required IapPlatform platform, - String? id, - super.currency, - super.localizedPrice, - super.title, - super.description, - String? type, - super.displayName, - String? displayPrice, - // iOS fields per OpenIAP spec - this.isFamilyShareableIOS, - this.jsonRepresentationIOS, - this.discountsIOS, - this.subscription, - this.introductoryPriceNumberOfPeriodsIOS, - this.introductoryPriceSubscriptionPeriodIOS, - this.subscriptionGroupIdIOS, - this.subscriptionPeriodUnitIOS, - this.subscriptionPeriodNumberIOS, - this.introductoryPricePaymentModeIOS, - this.environmentIOS, - this.promotionalOfferIdsIOS, - // Android fields per OpenIAP spec - this.nameAndroid, - this.oneTimePurchaseOfferDetailsAndroid, - this.originalPrice, - this.originalPriceAmount, - this.freeTrialPeriod, - this.iconUrl, - this.subscriptionOfferDetailsAndroid, - this.subscriptionPeriodAndroid, - this.introductoryPriceCyclesAndroid, - this.introductoryPricePeriodAndroid, - this.freeTrialPeriodAndroid, - this.signatureAndroid, - this.subscriptionOffersAndroid, - }) : super( - id: id ?? '', - type: type ?? 'subs', - displayPrice: displayPrice ?? (localizedPrice ?? price), - platformEnum: platform, - price: double.tryParse(price), - platform: platform == IapPlatform.ios ? 'ios' : 'android', - ); + /// Key identifier for validation + required this.keyIdentifier, - factory ProductSubscription.fromJson(Map json) { - return ProductSubscription( - id: json['id'] as String? ?? '', - price: _stringFromNumOrString(json['price']), - currency: json['currency'] as String?, - localizedPrice: json['localizedPrice'] as String?, - title: json['title'] as String?, - description: json['description'] as String?, - platform: - json['platform'] == 'android' ? IapPlatform.android : IapPlatform.ios, - type: json['type'] as String?, - // iOS fields per OpenIAP spec - displayName: json['displayName'] as String?, - displayPrice: json['displayPrice'] as String?, - discountsIOS: json['discountsIOS'] != null - ? (json['discountsIOS'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? DiscountIOS.fromJson(map) : null; - }) - .whereType() - .toList() - : null, - subscription: json['subscription'] != null - ? SubscriptionInfo.fromJson( - Map.from(json['subscription'] as Map), - ) - : null, - introductoryPriceNumberOfPeriodsIOS: - json['introductoryPriceNumberOfPeriodsIOS'] as String?, - introductoryPriceSubscriptionPeriodIOS: - json['introductoryPriceSubscriptionPeriodIOS'] as String?, - subscriptionGroupIdIOS: json['subscriptionGroupIdIOS'] as String?, - subscriptionPeriodUnitIOS: json['subscriptionPeriodUnitIOS'] as String?, - subscriptionPeriodNumberIOS: - json['subscriptionPeriodNumberIOS'] as String?, - introductoryPricePaymentModeIOS: - json['introductoryPricePaymentModeIOS'] as String?, - environmentIOS: json['environmentIOS'] as String?, - promotionalOfferIdsIOS: json['promotionalOfferIdsIOS'] != null - ? (json['promotionalOfferIdsIOS'] as List).cast() - : null, - // Android fields per OpenIAP spec - nameAndroid: json['nameAndroid'] as String?, - oneTimePurchaseOfferDetailsAndroid: - json['oneTimePurchaseOfferDetailsAndroid'] != null - ? Map.from( - json['oneTimePurchaseOfferDetailsAndroid'] as Map, - ) - : null, - originalPrice: json['originalPrice'] as String?, - originalPriceAmount: (json['originalPriceAmount'] as num?)?.toDouble(), - freeTrialPeriod: json['freeTrialPeriod'] as String?, - iconUrl: json['iconUrl'] as String?, - // Use new Android suffix field if available, fallback to old field for compatibility - subscriptionOfferDetailsAndroid: - json['subscriptionOfferDetailsAndroid'] != null - ? (json['subscriptionOfferDetailsAndroid'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? OfferDetail.fromJson(map) : null; - }) - .whereType() - .toList() - : null, - subscriptionPeriodAndroid: json['subscriptionPeriodAndroid'] as String?, - introductoryPriceCyclesAndroid: - json['introductoryPriceCyclesAndroid'] as String?, - introductoryPricePeriodAndroid: - json['introductoryPricePeriodAndroid'] as String?, - freeTrialPeriodAndroid: json['freeTrialPeriodAndroid'] as String?, - signatureAndroid: json['signatureAndroid'] as String?, - subscriptionOffersAndroid: json['subscriptionOffersAndroid'] != null - ? (json['subscriptionOffersAndroid'] as List) - .map( - (o) => SubscriptionOfferAndroid.fromJson( - _safeJsonMap(o) ?? {}, - ), - ) - .toList() - : null, - ); - } - - @override - String toString() { - final buffer = StringBuffer('ProductSubscription{\n'); - buffer.writeln(' productId: $id,'); - buffer.writeln(' id: $id,'); - buffer.writeln(' title: $title,'); - buffer.writeln(' description: $description,'); - buffer.writeln(' type: $type,'); - buffer.writeln(' price: $price,'); - buffer.writeln(' currency: $currency,'); - buffer.writeln(' localizedPrice: $localizedPrice,'); - buffer.writeln( - ' platform: ${platform ?? (platformEnum == IapPlatform.ios ? 'ios' : 'android')},', - ); - - // iOS specific fields (only show non-null) - if (displayName != null) buffer.writeln(' displayName: $displayName,'); - buffer.writeln(' displayPrice: $displayPrice,'); - if (isFamilyShareableIOS != null) { - buffer.writeln(' isFamilyShareableIOS: $isFamilyShareableIOS,'); - } - if (jsonRepresentationIOS != null) { - buffer.writeln( - ' jsonRepresentationIOS: ${jsonRepresentationIOS!.length > 100 ? '${jsonRepresentationIOS!.substring(0, 100)}...' : jsonRepresentationIOS},', - ); - } - if (environmentIOS != null) { - buffer.writeln(' environmentIOS: $environmentIOS,'); - } - if (subscriptionGroupIdIOS != null) { - buffer.writeln(' subscriptionGroupIdIOS: $subscriptionGroupIdIOS,'); - } - if (subscriptionPeriodUnitIOS != null) { - buffer.writeln( - ' subscriptionPeriodUnitIOS: $subscriptionPeriodUnitIOS,', - ); - } - if (subscriptionPeriodNumberIOS != null) { - buffer.writeln( - ' subscriptionPeriodNumberIOS: $subscriptionPeriodNumberIOS,', - ); - } - if (introductoryPriceNumberOfPeriodsIOS != null) { - buffer.writeln( - ' introductoryPriceNumberOfPeriodsIOS: $introductoryPriceNumberOfPeriodsIOS,', - ); - } - if (introductoryPriceSubscriptionPeriodIOS != null) { - buffer.writeln( - ' introductoryPriceSubscriptionPeriodIOS: $introductoryPriceSubscriptionPeriodIOS,', - ); - } - if (introductoryPricePaymentModeIOS != null) { - buffer.writeln( - ' introductoryPricePaymentModeIOS: $introductoryPricePaymentModeIOS,', - ); - } - if (promotionalOfferIdsIOS != null && promotionalOfferIdsIOS!.isNotEmpty) { - buffer.writeln( - ' promotionalOfferIdsIOS: ${promotionalOfferIdsIOS!.length} offer(s),', - ); - } - if (discountsIOS != null && discountsIOS!.isNotEmpty) { - buffer.writeln(' discountsIOS: ${discountsIOS!.length} discount(s),'); - } - if (subscription != null) { - buffer.writeln(' subscription: ${subscription.toString()},'); - } - - // Android specific fields (only show non-null) - if (originalPrice != null) { - buffer.writeln(' originalPrice: $originalPrice,'); - } - if (originalPriceAmount != null) { - buffer.writeln(' originalPriceAmount: $originalPriceAmount,'); - } - if (freeTrialPeriod != null) { - buffer.writeln(' freeTrialPeriod: $freeTrialPeriod,'); - } - if (iconUrl != null) buffer.writeln(' iconUrl: $iconUrl,'); - if (subscriptionPeriodAndroid != null) { - buffer.writeln( - ' subscriptionPeriodAndroid: $subscriptionPeriodAndroid,', - ); - } - if (subscriptionOffersAndroid != null && - subscriptionOffersAndroid!.isNotEmpty) { - buffer.writeln( - ' subscriptionOffersAndroid: ${subscriptionOffersAndroid!.length} offer(s),', - ); - } - - // Remove last comma and close - final str = buffer.toString(); - if (str.endsWith(',\n')) { - return '${str.substring(0, str.length - 2)}\n}'; - } - return '$str}'; - } - - /// Convert iOS native product types to OpenIAP standard types - String _convertTypeForOpenIAP(String type, bool isIOS) { - if (!isIOS) return type; // Android types are already correct - - switch (type.toLowerCase()) { - case 'consumable': - case 'nonconsumable': - case 'nonrenewable': - return 'inapp'; - case 'autorenewable': - return 'subs'; - default: - return type; // Return as-is if not recognized - } - } - - Map toJson() { - // Determine if this is iOS or Android - final isIOS = platformEnum == IapPlatform.ios; - - final json = { - 'id': id, - 'title': title ?? '', - 'description': description ?? '', - 'type': _convertTypeForOpenIAP(type, isIOS), - 'currency': currency ?? '', - 'platform': isIOS ? 'ios' : 'android', // Use string literal - }; - - // Price field (as number for iOS type compatibility) - if (price != null) { - json['price'] = price; - } + /// Cryptographic nonce + required this.nonce, - // displayPrice field (required for iOS) - json['displayPrice'] = displayPrice; - if (localizedPrice != null && displayPrice != localizedPrice) { - json['localizedPrice'] = localizedPrice; // Include if different - } + /// Signature for validation + required this.signature, - // Optional displayName field - if (displayName != null) { - json['displayName'] = displayName; - } + /// Timestamp of discount offer + required this.timestamp, + }); - // iOS specific fields with correct naming - if (isIOS) { - if (displayName != null) { - json['displayNameIOS'] = displayName; - } - // Add OpenIAP compliant iOS fields for ProductIOS - if (this is ProductIOS) { - final productIOS = this as ProductIOS; - if (productIOS.isFamilyShareableIOS != null) { - json['isFamilyShareableIOS'] = productIOS.isFamilyShareableIOS; - } - if (productIOS.jsonRepresentationIOS != null) { - json['jsonRepresentationIOS'] = productIOS.jsonRepresentationIOS; - } - } - // Add OpenIAP compliant iOS fields for Subscription - // Note: In Product class, this check is needed; in Subscription class, it's redundant - else // Remove unnecessary cast since we know the type - if ((this as dynamic).isFamilyShareableIOS != null) { - json['isFamilyShareableIOS'] = (this as dynamic).isFamilyShareableIOS; - } - if ((this as dynamic).jsonRepresentationIOS != null) { - json['jsonRepresentationIOS'] = (this as dynamic).jsonRepresentationIOS; - } + /// Discount identifier + final String identifier; - if (environmentIOS != null) { - json['environmentIOS'] = environmentIOS; - } - if (subscriptionGroupIdIOS != null) { - json['subscriptionGroupIdIOS'] = subscriptionGroupIdIOS; - } - if (promotionalOfferIdsIOS != null && - promotionalOfferIdsIOS!.isNotEmpty) { - json['promotionalOfferIdsIOS'] = promotionalOfferIdsIOS; - } - if (discountsIOS != null && discountsIOS!.isNotEmpty) { - json['discountsIOS'] = discountsIOS!.map((d) => d.toJson()).toList(); - } - // Add subscriptionInfoIOS with proper structure for OpenIAP - final subscriptionInfoJson = {}; + /// Key identifier for validation + final String keyIdentifier; - // Add subscriptionGroupId (convert to string for OpenIAP) - if (subscriptionGroupIdIOS != null) { - subscriptionInfoJson['subscriptionGroupId'] = - subscriptionGroupIdIOS.toString(); - } + /// Cryptographic nonce + final String nonce; - // Add subscriptionPeriod with proper structure - if (subscriptionPeriodUnitIOS != null && - subscriptionPeriodNumberIOS != null) { - subscriptionInfoJson['subscriptionPeriod'] = { - 'unit': subscriptionPeriodUnitIOS, - 'value': int.tryParse(subscriptionPeriodNumberIOS!) ?? 1, - }; - } + /// Signature for validation + final String signature; - // Merge existing subscription info if available - if (subscription != null) { - subscriptionInfoJson.addAll(subscription!.toJson()); - } + /// Timestamp of discount offer + final double timestamp; - if (subscriptionInfoJson.isNotEmpty) { - json['subscriptionInfoIOS'] = subscriptionInfoJson; - } + factory DiscountOfferIOS.fromJson(Map json) { + return DiscountOfferIOS( + identifier: json['identifier'] as String, + keyIdentifier: json['keyIdentifier'] as String, + nonce: json['nonce'] as String, + signature: json['signature'] as String, + timestamp: (json['timestamp'] as num).toDouble(), + ); + } - // Keep these fields for backward compatibility - if (subscriptionPeriodNumberIOS != null) { - json['subscriptionPeriodNumberIOS'] = subscriptionPeriodNumberIOS; - } - if (subscriptionPeriodUnitIOS != null) { - json['subscriptionPeriodUnitIOS'] = subscriptionPeriodUnitIOS; - } - if (introductoryPriceNumberOfPeriodsIOS != null) { - json['introductoryPriceNumberOfPeriodsIOS'] = - introductoryPriceNumberOfPeriodsIOS; - } - if (introductoryPriceSubscriptionPeriodIOS != null) { - json['introductoryPriceSubscriptionPeriodIOS'] = - introductoryPriceSubscriptionPeriodIOS; - } - if (introductoryPricePaymentModeIOS != null) { - json['introductoryPricePaymentModeIOS'] = - introductoryPricePaymentModeIOS; - } - } + Map toJson() { + return { + '__typename': 'DiscountOfferIOS', + 'identifier': identifier, + 'keyIdentifier': keyIdentifier, + 'nonce': nonce, + 'signature': signature, + 'timestamp': timestamp, + }; + } +} - // Android specific fields - if (!isIOS) { - if (nameAndroid != null) json['nameAndroid'] = nameAndroid; - if (oneTimePurchaseOfferDetailsAndroid != null) { - json['oneTimePurchaseOfferDetailsAndroid'] = - oneTimePurchaseOfferDetailsAndroid; - } - if (originalPrice != null) json['originalPrice'] = originalPrice; - if (originalPriceAmount != null) { - json['originalPriceAmount'] = originalPriceAmount; - } - if (freeTrialPeriod != null) json['freeTrialPeriod'] = freeTrialPeriod; - if (iconUrl != null) json['iconUrl'] = iconUrl; - // TODO(v6.4.0): Show subscription offer fields only on Android platform - // Always show Android suffix field (TypeScript compatible) - if (subscriptionOfferDetailsAndroid != null && - subscriptionOfferDetailsAndroid!.isNotEmpty) { - json['subscriptionOfferDetailsAndroid'] = - subscriptionOfferDetailsAndroid!.map((o) => o.toJson()).toList(); - } - if (subscriptionOffersAndroid != null && - subscriptionOffersAndroid!.isNotEmpty) { - json['subscriptionOffersAndroid'] = - subscriptionOffersAndroid!.map((o) => o.toJson()).toList(); - } - } +class EntitlementIOS { + const EntitlementIOS({ + required this.jsonRepresentation, + required this.sku, + required this.transactionId, + }); - return json; - } -} - -/// iOS-specific product class (OpenIAP compliant) -class ProductIOS extends Product { - // OpenIAP compliant iOS fields - final bool? isFamilyShareableIOS; - final String? jsonRepresentationIOS; - // Additional iOS fields - final String? subscriptionGroupIdentifier; - final String? subscriptionPeriodUnit; - final String? subscriptionPeriodNumber; - final String? introductoryPricePaymentMode; - final String? environment; // "Sandbox" | "Production" - final List? promotionalOfferIds; - final List? discounts; - - ProductIOS({ - required String price, - super.id, - super.currency, - super.localizedPrice, - super.title, - super.description, - super.type, - super.displayName, - bool? isFamilyShareable, - String? jsonRepresentation, - super.subscription, - super.introductoryPriceNumberOfPeriodsIOS, - super.introductoryPriceSubscriptionPeriodIOS, - // OpenIAP compliant iOS fields - this.isFamilyShareableIOS, - this.jsonRepresentationIOS, - // Additional iOS fields - this.subscriptionGroupIdentifier, - this.subscriptionPeriodUnit, - this.subscriptionPeriodNumber, - this.introductoryPricePaymentMode, - this.environment, - this.promotionalOfferIds, - this.discounts, - }) : super( - priceString: price, - platformEnum: IapPlatform.ios, - subscriptionGroupIdIOS: subscriptionGroupIdentifier, - subscriptionPeriodUnitIOS: subscriptionPeriodUnit, - subscriptionPeriodNumberIOS: subscriptionPeriodNumber, - introductoryPricePaymentModeIOS: introductoryPricePaymentMode, - environmentIOS: environment, - promotionalOfferIdsIOS: promotionalOfferIds, - discountsIOS: discounts, - ); + final String jsonRepresentation; + final String sku; + final String transactionId; - factory ProductIOS.fromJson(Map json) { - return ProductIOS( - id: (json['id'] as String?) ?? - (json['productId'] as String?) ?? - (json['sku'] as String?) ?? - (json['productIdentifier'] as String?), - price: _stringFromNumOrString(json['price']), - currency: json['currency'] as String?, - localizedPrice: json['localizedPrice'] as String?, - title: json['title'] as String?, - description: json['description'] as String?, - type: json['type'] as String?, - displayName: json['displayName'] as String?, - // OpenIAP compliant iOS fields - isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool? ?? - json['isFamilyShareable'] as bool?, - jsonRepresentationIOS: json['jsonRepresentationIOS'] as String? ?? - json['jsonRepresentation'] as String?, - // Additional iOS fields - subscriptionGroupIdentifier: - json['subscriptionGroupIdentifier'] as String?, - subscriptionPeriodUnit: json['subscriptionPeriodUnit'] as String?, - subscriptionPeriodNumber: json['subscriptionPeriodNumber'] as String?, - introductoryPricePaymentMode: - json['introductoryPricePaymentMode'] as String?, - environment: json['environment'] as String?, - promotionalOfferIds: json['promotionalOfferIds'] != null - ? (json['promotionalOfferIds'] as List).cast() - : null, - discounts: json['discounts'] != null - ? (json['discounts'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? DiscountIOS.fromJson(map) : null; - }) - .whereType() - .toList() - : null, + factory EntitlementIOS.fromJson(Map json) { + return EntitlementIOS( + jsonRepresentation: json['jsonRepresentation'] as String, + sku: json['sku'] as String, + transactionId: json['transactionId'] as String, ); } + + Map toJson() { + return { + '__typename': 'EntitlementIOS', + 'jsonRepresentation': jsonRepresentation, + 'sku': sku, + 'transactionId': transactionId, + }; + } } -/// Android-specific product class (OpenIAP compliant) -class ProductAndroid extends Product { - final String? subscriptionPeriod; - final String? introductoryPriceCycles; - final String? introductoryPricePeriod; - final String? signature; - final List? subscriptionOffers; - - ProductAndroid({ - required String price, - super.id, - super.currency, - super.localizedPrice, - super.title, - super.description, - super.type, - super.originalPrice, - super.originalPriceAmount, - super.iconUrl, - super.freeTrialPeriod, - super.subscriptionOfferDetailsAndroid, - this.subscriptionPeriod, - this.introductoryPriceCycles, - this.introductoryPricePeriod, - this.signature, - this.subscriptionOffers, - }) : super( - priceString: price, - platformEnum: IapPlatform.android, - subscriptionPeriodAndroid: subscriptionPeriod, - introductoryPriceCyclesAndroid: introductoryPriceCycles, - introductoryPricePeriodAndroid: introductoryPricePeriod, - freeTrialPeriodAndroid: freeTrialPeriod, - signatureAndroid: signature, - subscriptionOffersAndroid: subscriptionOffers, - ); +class FetchProductsResult { + const FetchProductsResult({ + this.products, + this.subscriptions, + }); - factory ProductAndroid.fromJson(Map json) { - return ProductAndroid( - id: (json['id'] as String?) ?? (json['productId'] as String?), - price: _stringFromNumOrString(json['price']), - currency: json['currency'] as String?, - localizedPrice: json['localizedPrice'] as String?, - title: json['title'] as String?, - description: json['description'] as String?, - type: json['type'] as String?, - originalPrice: json['originalPrice'] as String?, - subscriptionPeriod: json['subscriptionPeriod'] as String?, - introductoryPriceCycles: json['introductoryPriceCycles'] as String?, - introductoryPricePeriod: json['introductoryPricePeriod'] as String?, - freeTrialPeriod: json['freeTrialPeriod'] as String?, - signature: json['signature'] as String?, - subscriptionOffers: json['subscriptionOffers'] != null - ? (json['subscriptionOffers'] as List) - .map( - (o) => SubscriptionOfferAndroid.fromJson( - _safeJsonMap(o) ?? {}, - ), - ) - .toList() - : null, - originalPriceAmount: (json['originalPriceAmount'] as num?)?.toDouble(), - iconUrl: json['iconUrl'] as String?, - // Note: subscriptionOfferDetailsAndroid is only in Product class + final List? products; + final List? subscriptions; + + factory FetchProductsResult.fromJson(Map json) { + return FetchProductsResult( + products: (json['products'] as List?) == null + ? null + : (json['products'] as List?)! + .map((e) => Product.fromJson(e as Map)) + .toList(), + subscriptions: (json['subscriptions'] as List?) == null + ? null + : (json['subscriptions'] as List?)! + .map((e) => + ProductSubscription.fromJson(e as Map)) + .toList(), ); } -} -/// Recurrence mode enum (OpenIAP compliant) -enum RecurrenceMode { infiniteRecurring, finiteRecurring, nonRecurring } - -/// Subscription info for iOS (OpenIAP compliant) -class SubscriptionInfo { - final String? subscriptionGroupId; - final Map? subscriptionPeriod; - final Map? introductoryOffer; - final List? promotionalOffers; - final String? introductoryPrice; + Map toJson() { + return { + '__typename': 'FetchProductsResult', + 'products': + products == null ? null : products!.map((e) => e.toJson()).toList(), + 'subscriptions': subscriptions == null + ? null + : subscriptions!.map((e) => e.toJson()).toList(), + }; + } +} - SubscriptionInfo({ - this.subscriptionGroupId, - this.subscriptionPeriod, - this.introductoryOffer, - this.promotionalOffers, - this.introductoryPrice, +class PricingPhaseAndroid { + const PricingPhaseAndroid({ + required this.billingCycleCount, + required this.billingPeriod, + required this.formattedPrice, + required this.priceAmountMicros, + required this.priceCurrencyCode, + required this.recurrenceMode, }); - factory SubscriptionInfo.fromJson(Map json) { - return SubscriptionInfo( - subscriptionGroupId: json['subscriptionGroupId'] as String?, - subscriptionPeriod: json['subscriptionPeriod'] != null - ? Map.from(json['subscriptionPeriod'] as Map) - : null, - introductoryOffer: json['introductoryOffer'] != null - ? Map.from(json['introductoryOffer'] as Map) - : null, - promotionalOffers: json['promotionalOffers'] as List?, - introductoryPrice: json['introductoryPrice'] as String?, + final int billingCycleCount; + final String billingPeriod; + final String formattedPrice; + final String priceAmountMicros; + final String priceCurrencyCode; + final int recurrenceMode; + + factory PricingPhaseAndroid.fromJson(Map json) { + return PricingPhaseAndroid( + billingCycleCount: json['billingCycleCount'] as int, + billingPeriod: json['billingPeriod'] as String, + formattedPrice: json['formattedPrice'] as String, + priceAmountMicros: json['priceAmountMicros'] as String, + priceCurrencyCode: json['priceCurrencyCode'] as String, + recurrenceMode: json['recurrenceMode'] as int, ); } Map toJson() { - final json = {}; - if (subscriptionGroupId != null) { - json['subscriptionGroupId'] = subscriptionGroupId; - } - if (subscriptionPeriod != null) { - json['subscriptionPeriod'] = subscriptionPeriod; - } - if (introductoryOffer != null) { - json['introductoryOffer'] = introductoryOffer; - } - if (promotionalOffers != null) { - json['promotionalOffers'] = promotionalOffers; - } - if (introductoryPrice != null) { - json['introductoryPrice'] = introductoryPrice; - } - return json; + return { + '__typename': 'PricingPhaseAndroid', + 'billingCycleCount': billingCycleCount, + 'billingPeriod': billingPeriod, + 'formattedPrice': formattedPrice, + 'priceAmountMicros': priceAmountMicros, + 'priceCurrencyCode': priceCurrencyCode, + 'recurrenceMode': recurrenceMode, + }; } } -/// Introductory price info -class IntroductoryPrice { - final double priceValue; - final String priceString; - final String period; - final int cycles; - final String? paymentMode; - final int? paymentModeValue; - - IntroductoryPrice({ - required this.priceValue, - required this.priceString, - required this.period, - required this.cycles, - this.paymentMode, - this.paymentModeValue, +class PricingPhasesAndroid { + const PricingPhasesAndroid({ + required this.pricingPhaseList, }); -} -/// Promotional offer -class PromotionalOffer { - final double priceValue; - final String priceString; - final int cycles; - final String period; - final String? paymentMode; - final int? paymentModeValue; + final List pricingPhaseList; - PromotionalOffer({ - required this.priceValue, - required this.priceString, - required this.cycles, - required this.period, - this.paymentMode, - this.paymentModeValue, - }); -} + factory PricingPhasesAndroid.fromJson(Map json) { + return PricingPhasesAndroid( + pricingPhaseList: (json['pricingPhaseList'] as List) + .map((e) => PricingPhaseAndroid.fromJson(e as Map)) + .toList(), + ); + } -/// Offer detail for Android (OpenIAP compliant) -class OfferDetail { - final String basePlanId; - final String? offerId; - final List pricingPhases; - final String? offerToken; - final List? offerTags; + Map toJson() { + return { + '__typename': 'PricingPhasesAndroid', + 'pricingPhaseList': pricingPhaseList.map((e) => e.toJson()).toList(), + }; + } +} - OfferDetail({ - required this.basePlanId, - required this.pricingPhases, - this.offerId, - this.offerToken, - this.offerTags, +class ProductAndroid extends Product implements ProductCommon { + const ProductAndroid({ + required this.currency, + this.debugDescription, + required this.description, + this.displayName, + required this.displayPrice, + required this.id, + required this.nameAndroid, + this.oneTimePurchaseOfferDetailsAndroid, + required this.platform, + this.price, + this.subscriptionOfferDetailsAndroid, + required this.title, + required this.type, }); - factory OfferDetail.fromJson(Map json) { - // Handle pricingPhases which can be either: - // 1. A list of phases directly (legacy) - // 2. An object with 'pricingPhaseList' property (new Android structure) - List phases; - final pricingPhasesData = json['pricingPhases']; - - if (pricingPhasesData is List) { - // Legacy format: direct list - phases = pricingPhasesData - .map((item) { - final map = _safeJsonMap(item); - return map != null ? PricingPhase.fromJson(map) : null; - }) - .whereType() - .toList(); - } else if (pricingPhasesData is Map && - pricingPhasesData['pricingPhaseList'] != null) { - // New Android format: object with pricingPhaseList - phases = (pricingPhasesData['pricingPhaseList'] as List) - .map((item) { - final map = _safeJsonMap(item); - return map != null ? PricingPhase.fromJson(map) : null; - }) - .whereType() - .toList(); - } else { - phases = []; - } + final String currency; + final String? debugDescription; + final String description; + final String? displayName; + final String displayPrice; + final String id; + final String nameAndroid; + final ProductAndroidOneTimePurchaseOfferDetail? + oneTimePurchaseOfferDetailsAndroid; + final IapPlatform platform; + final double? price; + final List? + subscriptionOfferDetailsAndroid; + final String title; + final ProductType type; - return OfferDetail( - basePlanId: json['basePlanId'] as String? ?? '', - offerId: json['offerId'] as String?, - pricingPhases: phases, - offerToken: json['offerToken'] as String?, - offerTags: (json['offerTags'] as List?)?.cast(), + factory ProductAndroid.fromJson(Map json) { + return ProductAndroid( + currency: json['currency'] as String, + debugDescription: json['debugDescription'] as String?, + description: json['description'] as String, + displayName: json['displayName'] as String?, + displayPrice: json['displayPrice'] as String, + id: json['id'] as String, + nameAndroid: json['nameAndroid'] as String, + oneTimePurchaseOfferDetailsAndroid: + json['oneTimePurchaseOfferDetailsAndroid'] != null + ? ProductAndroidOneTimePurchaseOfferDetail.fromJson( + json['oneTimePurchaseOfferDetailsAndroid'] + as Map) + : null, + 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(), + title: json['title'] as String, + type: ProductType.fromJson(json['type'] as String), ); } + @override Map toJson() { - final json = { - 'basePlanId': basePlanId, - 'offerId': offerId, // Always include offerId (can be null) - // Use nested structure to match TypeScript type - 'pricingPhases': { - 'pricingPhaseList': pricingPhases.map((p) => p.toJson()).toList(), - }, - 'offerToken': offerToken, - 'offerTags': offerTags ?? [], + return { + '__typename': 'ProductAndroid', + 'currency': currency, + 'debugDescription': debugDescription, + 'description': description, + 'displayName': displayName, + 'displayPrice': displayPrice, + 'id': id, + 'nameAndroid': nameAndroid, + 'oneTimePurchaseOfferDetailsAndroid': + oneTimePurchaseOfferDetailsAndroid?.toJson(), + 'platform': platform.toJson(), + 'price': price, + 'subscriptionOfferDetailsAndroid': subscriptionOfferDetailsAndroid == null + ? null + : subscriptionOfferDetailsAndroid!.map((e) => e.toJson()).toList(), + 'title': title, + 'type': type.toJson(), }; - return json; } } -/// Verification result for iOS (OpenIAP compliant) -class VerificationResult { - final bool isVerified; - final String? verificationError; - final Map? data; - - VerificationResult({ - required this.isVerified, - this.verificationError, - this.data, +class ProductAndroidOneTimePurchaseOfferDetail { + const ProductAndroidOneTimePurchaseOfferDetail({ + required this.formattedPrice, + required this.priceAmountMicros, + required this.priceCurrencyCode, }); -} -/// Subscription offer details (kept for compatibility) -class SubscriptionOffer { - final String sku; - final String offerToken; - final List pricingPhases; + final String formattedPrice; + final String priceAmountMicros; + final String priceCurrencyCode; - SubscriptionOffer({ - required this.sku, - required this.offerToken, - required this.pricingPhases, - }); -} + factory ProductAndroidOneTimePurchaseOfferDetail.fromJson( + Map json) { + return ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice: json['formattedPrice'] as String, + priceAmountMicros: json['priceAmountMicros'] as String, + priceCurrencyCode: json['priceCurrencyCode'] as String, + ); + } -/// Pricing phase for subscriptions (OpenIAP compliant) -class PricingPhase { - final double priceAmount; - final String price; - final String currency; - final String? billingPeriod; - final int? billingCycleCount; - final RecurrenceMode? recurrenceMode; + Map toJson() { + return { + '__typename': 'ProductAndroidOneTimePurchaseOfferDetail', + 'formattedPrice': formattedPrice, + 'priceAmountMicros': priceAmountMicros, + 'priceCurrencyCode': priceCurrencyCode, + }; + } +} - PricingPhase({ - required this.priceAmount, - required this.price, +class ProductIOS extends Product implements ProductCommon { + const ProductIOS({ required this.currency, - this.billingPeriod, - this.billingCycleCount, - this.recurrenceMode, + this.debugDescription, + required this.description, + this.displayName, + required this.displayNameIOS, + required this.displayPrice, + required this.id, + required this.isFamilyShareableIOS, + required this.jsonRepresentationIOS, + required this.platform, + this.price, + this.subscriptionInfoIOS, + required this.title, + required this.type, + required this.typeIOS, }); - factory PricingPhase.fromJson(Map json) { - // Handle different field names from Android native - double priceAmount; - if (json['priceAmount'] != null) { - priceAmount = (json['priceAmount'] as num).toDouble(); - } else if (json['priceAmountMicros'] != null) { - // Convert micros to regular amount - final micros = json['priceAmountMicros']; - if (micros is String) { - priceAmount = (int.tryParse(micros) ?? 0) / 1000000.0; - } else if (micros is num) { - priceAmount = micros / 1000000.0; - } else { - priceAmount = 0.0; - } - } else { - priceAmount = 0.0; - } + final String currency; + final String? debugDescription; + final String description; + final String? displayName; + final String displayNameIOS; + final String displayPrice; + final String id; + final bool isFamilyShareableIOS; + final String jsonRepresentationIOS; + final IapPlatform platform; + final double? price; + final SubscriptionInfoIOS? subscriptionInfoIOS; + final String title; + final ProductType type; + final ProductTypeIOS typeIOS; - return PricingPhase( - priceAmount: priceAmount, - price: - json['price'] as String? ?? json['formattedPrice'] as String? ?? '0', - currency: json['currency'] as String? ?? - json['priceCurrencyCode'] as String? ?? - 'USD', - billingPeriod: json['billingPeriod'] as String?, - billingCycleCount: json['billingCycleCount'] as int?, - recurrenceMode: json['recurrenceMode'] != null - ? RecurrenceMode.values[json['recurrenceMode'] as int] + factory ProductIOS.fromJson(Map json) { + return ProductIOS( + currency: json['currency'] as String, + debugDescription: json['debugDescription'] as String?, + description: json['description'] as String, + displayName: json['displayName'] as String?, + displayNameIOS: json['displayNameIOS'] as String, + displayPrice: json['displayPrice'] as String, + id: json['id'] as String, + isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool, + jsonRepresentationIOS: json['jsonRepresentationIOS'] as String, + platform: IapPlatform.fromJson(json['platform'] as String), + price: (json['price'] as num?)?.toDouble(), + subscriptionInfoIOS: json['subscriptionInfoIOS'] != null + ? SubscriptionInfoIOS.fromJson( + json['subscriptionInfoIOS'] as Map) : null, + title: json['title'] as String, + type: ProductType.fromJson(json['type'] as String), + typeIOS: ProductTypeIOS.fromJson(json['typeIOS'] as String), ); } + @override Map toJson() { - final json = { - // Use TypeScript-expected field names - 'formattedPrice': price, - 'priceCurrencyCode': currency, - 'priceAmountMicros': (priceAmount * 1000000).toStringAsFixed(0), + return { + '__typename': 'ProductIOS', + 'currency': currency, + 'debugDescription': debugDescription, + 'description': description, + 'displayName': displayName, + 'displayNameIOS': displayNameIOS, + 'displayPrice': displayPrice, + 'id': id, + 'isFamilyShareableIOS': isFamilyShareableIOS, + 'jsonRepresentationIOS': jsonRepresentationIOS, + 'platform': platform.toJson(), + 'price': price, + 'subscriptionInfoIOS': subscriptionInfoIOS?.toJson(), + 'title': title, + 'type': type.toJson(), + 'typeIOS': typeIOS.toJson(), }; - if (billingPeriod != null) json['billingPeriod'] = billingPeriod; - if (billingCycleCount != null) { - json['billingCycleCount'] = billingCycleCount; - } - if (recurrenceMode != null) json['recurrenceMode'] = recurrenceMode?.index; - return json; } } -/// Purchase class (OpenIAP compliant) -class Purchase { - final String productId; - final String? transactionId; - final int? transactionDate; // Unix timestamp in milliseconds - final String? transactionReceipt; - final String? purchaseToken; - final String? orderId; - final String? packageName; - final PurchaseState? purchaseState; - final bool? isAcknowledged; - final bool? autoRenewing; - final String? originalJson; - final String? developerPayload; - final String? originalOrderId; - final int? purchaseTime; +class ProductSubscriptionAndroid extends ProductSubscription + implements ProductCommon { + const ProductSubscriptionAndroid({ + required this.currency, + this.debugDescription, + required this.description, + this.displayName, + required this.displayPrice, + required this.id, + required this.nameAndroid, + this.oneTimePurchaseOfferDetailsAndroid, + required this.platform, + this.price, + required this.subscriptionOfferDetailsAndroid, + required this.title, + required this.type, + }); + + final String currency; + final String? debugDescription; + final String description; + final String? displayName; + final String displayPrice; + final String id; + final String nameAndroid; + final ProductAndroidOneTimePurchaseOfferDetail? + oneTimePurchaseOfferDetailsAndroid; final IapPlatform platform; - // iOS specific fields per OpenIAP spec - final String? originalTransactionDateIOS; - final String? originalTransactionIdentifierIOS; - final bool? isUpgradeIOS; - final TransactionState? transactionStateIOS; - final VerificationResult? verificationResultIOS; - final String? environmentIOS; // "Sandbox" | "Production" - final DateTime? expirationDateIOS; - final DateTime? revocationDateIOS; - final String? revocationReasonIOS; - final String? appAccountTokenIOS; - final String? webOrderLineItemIdIOS; - final String? subscriptionGroupIdIOS; - final bool? isUpgradedIOS; - final String? offerCodeRefNameIOS; - final String? offerIdentifierIOS; - final int? offerTypeIOS; - final String? signedDateIOS; - final String? storeFrontIOS; - final String? storeFrontCountryCodeIOS; - final String? currencyCodeIOS; - final double? priceIOS; - final String? jsonRepresentationIOS; - final bool? isFinishedIOS; - final int? quantityIOS; - final String? appBundleIdIOS; - final String? productTypeIOS; - final String? ownershipTypeIOS; - final String? transactionReasonIOS; - final String? reasonIOS; - final Map? offerIOS; - final String? jwsRepresentationIOS; - // Android specific fields per OpenIAP spec - final String? signatureAndroid; + final double? price; + final List + subscriptionOfferDetailsAndroid; + final String title; + final ProductType type; + + factory ProductSubscriptionAndroid.fromJson(Map json) { + return ProductSubscriptionAndroid( + currency: json['currency'] as String, + debugDescription: json['debugDescription'] as String?, + description: json['description'] as String, + displayName: json['displayName'] as String?, + displayPrice: json['displayPrice'] as String, + id: json['id'] as String, + nameAndroid: json['nameAndroid'] as String, + oneTimePurchaseOfferDetailsAndroid: + json['oneTimePurchaseOfferDetailsAndroid'] != null + ? ProductAndroidOneTimePurchaseOfferDetail.fromJson( + json['oneTimePurchaseOfferDetailsAndroid'] + as Map) + : null, + 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(), + title: json['title'] as String, + type: ProductType.fromJson(json['type'] as String), + ); + } + + @override + Map toJson() { + return { + '__typename': 'ProductSubscriptionAndroid', + 'currency': currency, + 'debugDescription': debugDescription, + 'description': description, + 'displayName': displayName, + 'displayPrice': displayPrice, + 'id': id, + 'nameAndroid': nameAndroid, + 'oneTimePurchaseOfferDetailsAndroid': + oneTimePurchaseOfferDetailsAndroid?.toJson(), + 'platform': platform.toJson(), + 'price': price, + 'subscriptionOfferDetailsAndroid': + subscriptionOfferDetailsAndroid.map((e) => e.toJson()).toList(), + 'title': title, + 'type': type.toJson(), + }; + } +} + +class ProductSubscriptionAndroidOfferDetails { + const ProductSubscriptionAndroidOfferDetails({ + required this.basePlanId, + this.offerId, + required this.offerTags, + required this.offerToken, + required this.pricingPhases, + }); + + final String basePlanId; + final String? offerId; + final List offerTags; + final String offerToken; + final PricingPhasesAndroid pricingPhases; + + factory ProductSubscriptionAndroidOfferDetails.fromJson( + Map json) { + return ProductSubscriptionAndroidOfferDetails( + basePlanId: json['basePlanId'] as String, + offerId: json['offerId'] as String?, + offerTags: + (json['offerTags'] as List).map((e) => e as String).toList(), + offerToken: json['offerToken'] as String, + pricingPhases: PricingPhasesAndroid.fromJson( + json['pricingPhases'] as Map), + ); + } + + Map toJson() { + return { + '__typename': 'ProductSubscriptionAndroidOfferDetails', + 'basePlanId': basePlanId, + 'offerId': offerId, + 'offerTags': offerTags.map((e) => e).toList(), + 'offerToken': offerToken, + 'pricingPhases': pricingPhases.toJson(), + }; + } +} + +class ProductSubscriptionIOS extends ProductSubscription + implements ProductCommon { + const ProductSubscriptionIOS({ + required this.currency, + this.debugDescription, + required this.description, + this.discountsIOS, + this.displayName, + required this.displayNameIOS, + required this.displayPrice, + required this.id, + this.introductoryPriceAsAmountIOS, + this.introductoryPriceIOS, + this.introductoryPriceNumberOfPeriodsIOS, + this.introductoryPricePaymentModeIOS, + this.introductoryPriceSubscriptionPeriodIOS, + required this.isFamilyShareableIOS, + required this.jsonRepresentationIOS, + required this.platform, + this.price, + this.subscriptionInfoIOS, + this.subscriptionPeriodNumberIOS, + this.subscriptionPeriodUnitIOS, + required this.title, + required this.type, + required this.typeIOS, + }); + + final String currency; + final String? debugDescription; + final String description; + final List? discountsIOS; + final String? displayName; + final String displayNameIOS; + final String displayPrice; + final String id; + final String? introductoryPriceAsAmountIOS; + final String? introductoryPriceIOS; + final String? introductoryPriceNumberOfPeriodsIOS; + final PaymentModeIOS? introductoryPricePaymentModeIOS; + final SubscriptionPeriodIOS? introductoryPriceSubscriptionPeriodIOS; + final bool isFamilyShareableIOS; + final String jsonRepresentationIOS; + final IapPlatform platform; + final double? price; + final SubscriptionInfoIOS? subscriptionInfoIOS; + final String? subscriptionPeriodNumberIOS; + final SubscriptionPeriodIOS? subscriptionPeriodUnitIOS; + final String title; + final ProductType type; + final ProductTypeIOS typeIOS; + + factory ProductSubscriptionIOS.fromJson(Map json) { + return ProductSubscriptionIOS( + currency: json['currency'] as String, + debugDescription: json['debugDescription'] as String?, + description: json['description'] as String, + discountsIOS: (json['discountsIOS'] as List?) == null + ? null + : (json['discountsIOS'] as List?)! + .map((e) => DiscountIOS.fromJson(e as Map)) + .toList(), + displayName: json['displayName'] as String?, + displayNameIOS: json['displayNameIOS'] as String, + displayPrice: json['displayPrice'] as String, + id: json['id'] as String, + introductoryPriceAsAmountIOS: + json['introductoryPriceAsAmountIOS'] as String?, + introductoryPriceIOS: json['introductoryPriceIOS'] as String?, + introductoryPriceNumberOfPeriodsIOS: + json['introductoryPriceNumberOfPeriodsIOS'] as String?, + introductoryPricePaymentModeIOS: + json['introductoryPricePaymentModeIOS'] != null + ? PaymentModeIOS.fromJson( + json['introductoryPricePaymentModeIOS'] as String) + : null, + introductoryPriceSubscriptionPeriodIOS: + json['introductoryPriceSubscriptionPeriodIOS'] != null + ? SubscriptionPeriodIOS.fromJson( + json['introductoryPriceSubscriptionPeriodIOS'] as String) + : null, + isFamilyShareableIOS: json['isFamilyShareableIOS'] as bool, + jsonRepresentationIOS: json['jsonRepresentationIOS'] as String, + platform: IapPlatform.fromJson(json['platform'] as String), + price: (json['price'] as num?)?.toDouble(), + subscriptionInfoIOS: json['subscriptionInfoIOS'] != null + ? SubscriptionInfoIOS.fromJson( + json['subscriptionInfoIOS'] as Map) + : null, + subscriptionPeriodNumberIOS: + json['subscriptionPeriodNumberIOS'] as String?, + subscriptionPeriodUnitIOS: json['subscriptionPeriodUnitIOS'] != null + ? SubscriptionPeriodIOS.fromJson( + json['subscriptionPeriodUnitIOS'] as String) + : null, + title: json['title'] as String, + type: ProductType.fromJson(json['type'] as String), + typeIOS: ProductTypeIOS.fromJson(json['typeIOS'] as String), + ); + } + + @override + Map toJson() { + return { + '__typename': 'ProductSubscriptionIOS', + 'currency': currency, + 'debugDescription': debugDescription, + 'description': description, + 'discountsIOS': discountsIOS == null + ? null + : discountsIOS!.map((e) => e.toJson()).toList(), + 'displayName': displayName, + 'displayNameIOS': displayNameIOS, + 'displayPrice': displayPrice, + 'id': id, + 'introductoryPriceAsAmountIOS': introductoryPriceAsAmountIOS, + 'introductoryPriceIOS': introductoryPriceIOS, + 'introductoryPriceNumberOfPeriodsIOS': + introductoryPriceNumberOfPeriodsIOS, + 'introductoryPricePaymentModeIOS': + introductoryPricePaymentModeIOS?.toJson(), + 'introductoryPriceSubscriptionPeriodIOS': + introductoryPriceSubscriptionPeriodIOS?.toJson(), + 'isFamilyShareableIOS': isFamilyShareableIOS, + 'jsonRepresentationIOS': jsonRepresentationIOS, + 'platform': platform.toJson(), + 'price': price, + 'subscriptionInfoIOS': subscriptionInfoIOS?.toJson(), + 'subscriptionPeriodNumberIOS': subscriptionPeriodNumberIOS, + 'subscriptionPeriodUnitIOS': subscriptionPeriodUnitIOS?.toJson(), + 'title': title, + 'type': type.toJson(), + 'typeIOS': typeIOS.toJson(), + }; + } +} + +class PurchaseAndroid extends Purchase implements PurchaseCommon { + const PurchaseAndroid({ + this.autoRenewingAndroid, + this.dataAndroid, + this.developerPayloadAndroid, + required this.id, + this.ids, + this.isAcknowledgedAndroid, + required this.isAutoRenewing, + this.obfuscatedAccountIdAndroid, + this.obfuscatedProfileIdAndroid, + this.packageNameAndroid, + required this.platform, + required this.productId, + required this.purchaseState, + this.purchaseToken, + required this.quantity, + this.signatureAndroid, + required this.transactionDate, + }); + final bool? autoRenewingAndroid; - final String? orderIdAndroid; - final String? packageNameAndroid; + final String? dataAndroid; final String? developerPayloadAndroid; - final bool? acknowledgedAndroid; + final String id; + final List? ids; final bool? isAcknowledgedAndroid; - final int? purchaseStateAndroid; - final String? purchaseTokenAndroid; - final String? dataAndroid; + final bool isAutoRenewing; final String? obfuscatedAccountIdAndroid; final String? obfuscatedProfileIdAndroid; - final String? originalJsonAndroid; - final List? productsAndroid; // For multi-SKU purchases - final List? skusAndroid; // Legacy field - final bool? isAutoRenewingAndroid; // Duplicate for compatibility - final String? replacementTokenAndroid; - final int? priceAmountMicrosAndroid; - final String? priceCurrencyCodeAndroid; - final String? countryCodeAndroid; - // ProductPurchase fields (legacy) - final bool? isConsumedAndroid; - - /// OpenIAP compatibility: id field maps to transactionId (not productId!) - /// Returns transactionId if available, otherwise returns empty string - String get id => transactionId ?? ''; - - /// OpenIAP compatibility: ids array containing the productId - List get ids => [productId]; - - /// Common quantity field (OpenIAP compliant) - /// iOS supports quantity; Android defaults to 1 - int get quantity => quantityIOS ?? 1; - - /// Common auto-renew flag across platforms - bool get isAutoRenewing => - autoRenewing == true || - autoRenewingAndroid == true || - isAutoRenewingAndroid == true; - - Purchase({ - required this.productId, - required this.platform, - this.transactionId, - this.transactionDate, - this.transactionReceipt, - this.purchaseToken, - this.orderId, - this.packageName, - this.purchaseState, - this.isAcknowledged, - this.autoRenewing, - this.originalJson, - this.developerPayload, - this.originalOrderId, - this.purchaseTime, - // iOS specific per OpenIAP spec - this.originalTransactionDateIOS, - this.originalTransactionIdentifierIOS, - this.isUpgradeIOS, - this.transactionStateIOS, - this.verificationResultIOS, + final String? packageNameAndroid; + final IapPlatform platform; + final String productId; + final PurchaseState purchaseState; + final String? purchaseToken; + final int quantity; + final String? signatureAndroid; + final double transactionDate; + + factory PurchaseAndroid.fromJson(Map json) { + return PurchaseAndroid( + autoRenewingAndroid: json['autoRenewingAndroid'] as bool?, + dataAndroid: json['dataAndroid'] as String?, + developerPayloadAndroid: json['developerPayloadAndroid'] as String?, + id: json['id'] as String, + ids: (json['ids'] as List?) == null + ? null + : (json['ids'] as List?)!.map((e) => e as String).toList(), + isAcknowledgedAndroid: json['isAcknowledgedAndroid'] as bool?, + isAutoRenewing: json['isAutoRenewing'] as bool, + obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?, + obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?, + packageNameAndroid: json['packageNameAndroid'] as String?, + platform: IapPlatform.fromJson(json['platform'] as String), + productId: json['productId'] as String, + purchaseState: PurchaseState.fromJson(json['purchaseState'] as String), + purchaseToken: json['purchaseToken'] as String?, + quantity: json['quantity'] as int, + signatureAndroid: json['signatureAndroid'] as String?, + transactionDate: (json['transactionDate'] as num).toDouble(), + ); + } + + @override + Map toJson() { + return { + '__typename': 'PurchaseAndroid', + 'autoRenewingAndroid': autoRenewingAndroid, + 'dataAndroid': dataAndroid, + 'developerPayloadAndroid': developerPayloadAndroid, + 'id': id, + 'ids': ids == null ? null : ids!.map((e) => e).toList(), + 'isAcknowledgedAndroid': isAcknowledgedAndroid, + 'isAutoRenewing': isAutoRenewing, + 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid, + 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid, + 'packageNameAndroid': packageNameAndroid, + 'platform': platform.toJson(), + 'productId': productId, + 'purchaseState': purchaseState.toJson(), + 'purchaseToken': purchaseToken, + 'quantity': quantity, + 'signatureAndroid': signatureAndroid, + 'transactionDate': transactionDate, + }; + } +} + +class PurchaseError { + const PurchaseError({ + required this.code, + required this.message, + this.productId, + }); + + final ErrorCode code; + final String message; + final String? productId; + + factory PurchaseError.fromJson(Map json) { + return PurchaseError( + code: ErrorCode.fromJson(json['code'] as String), + message: json['message'] as String, + productId: json['productId'] as String?, + ); + } + + Map toJson() { + return { + '__typename': 'PurchaseError', + 'code': code.toJson(), + 'message': message, + 'productId': productId, + }; + } +} + +class PurchaseIOS extends Purchase implements PurchaseCommon { + const PurchaseIOS({ + this.appAccountToken, + this.appBundleIdIOS, + this.countryCodeIOS, + this.currencyCodeIOS, + this.currencySymbolIOS, this.environmentIOS, this.expirationDateIOS, + required this.id, + this.ids, + required this.isAutoRenewing, + this.isUpgradedIOS, + this.offerIOS, + this.originalTransactionDateIOS, + this.originalTransactionIdentifierIOS, + this.ownershipTypeIOS, + required this.platform, + required this.productId, + required this.purchaseState, + this.purchaseToken, + required this.quantity, + this.quantityIOS, + this.reasonIOS, + this.reasonStringRepresentationIOS, this.revocationDateIOS, this.revocationReasonIOS, - this.appAccountTokenIOS, - this.webOrderLineItemIdIOS, + this.storefrontCountryCodeIOS, this.subscriptionGroupIdIOS, - this.isUpgradedIOS, - this.offerCodeRefNameIOS, - this.offerIdentifierIOS, - this.offerTypeIOS, - this.signedDateIOS, - this.storeFrontIOS, - this.storeFrontCountryCodeIOS, - this.currencyCodeIOS, - this.priceIOS, - this.jsonRepresentationIOS, - this.isFinishedIOS, - this.quantityIOS, - this.appBundleIdIOS, - this.productTypeIOS, - this.ownershipTypeIOS, + required this.transactionDate, this.transactionReasonIOS, - this.reasonIOS, - this.offerIOS, - this.jwsRepresentationIOS, - // Android specific per OpenIAP spec - this.signatureAndroid, - this.autoRenewingAndroid, - this.orderIdAndroid, - this.packageNameAndroid, - this.developerPayloadAndroid, - this.acknowledgedAndroid, - this.isAcknowledgedAndroid, - this.purchaseStateAndroid, - this.purchaseTokenAndroid, - this.dataAndroid, - this.obfuscatedAccountIdAndroid, - this.obfuscatedProfileIdAndroid, - this.originalJsonAndroid, - this.productsAndroid, - this.skusAndroid, - this.isAutoRenewingAndroid, - this.replacementTokenAndroid, - this.priceAmountMicrosAndroid, - this.priceCurrencyCodeAndroid, - this.countryCodeAndroid, - // ProductPurchase fields - this.isConsumedAndroid, + this.webOrderLineItemIdIOS, }); - factory Purchase.fromJson(Map json) { - return Purchase( - productId: json['productId'] as String? ?? '', - transactionId: json['transactionId'] as String?, - transactionDate: json['transactionDate'] is int - ? json['transactionDate'] as int - : json['transactionDate'] is String - ? int.tryParse(json['transactionDate'] as String) - : null, - transactionReceipt: json['transactionReceipt'] as String?, - purchaseToken: json['purchaseToken'] as String?, - orderId: json['orderId'] as String?, - packageName: json['packageName'] as String?, - purchaseState: json['purchaseState'] != null - ? PurchaseState.values[json['purchaseState'] as int] + final String? appAccountToken; + final String? appBundleIdIOS; + final String? countryCodeIOS; + final String? currencyCodeIOS; + final String? currencySymbolIOS; + final String? environmentIOS; + final double? expirationDateIOS; + final String id; + final List? ids; + final bool isAutoRenewing; + final bool? isUpgradedIOS; + final PurchaseOfferIOS? offerIOS; + final double? originalTransactionDateIOS; + final String? originalTransactionIdentifierIOS; + final String? ownershipTypeIOS; + final IapPlatform platform; + final String productId; + final PurchaseState purchaseState; + final String? purchaseToken; + final int quantity; + final int? quantityIOS; + final String? reasonIOS; + final String? reasonStringRepresentationIOS; + final double? revocationDateIOS; + final String? revocationReasonIOS; + final String? storefrontCountryCodeIOS; + final String? subscriptionGroupIdIOS; + final double transactionDate; + final String? transactionReasonIOS; + final String? webOrderLineItemIdIOS; + + factory PurchaseIOS.fromJson(Map json) { + return PurchaseIOS( + appAccountToken: json['appAccountToken'] as String?, + appBundleIdIOS: json['appBundleIdIOS'] as String?, + countryCodeIOS: json['countryCodeIOS'] as String?, + currencyCodeIOS: json['currencyCodeIOS'] as String?, + currencySymbolIOS: json['currencySymbolIOS'] as String?, + environmentIOS: json['environmentIOS'] as String?, + expirationDateIOS: (json['expirationDateIOS'] as num?)?.toDouble(), + id: json['id'] as String, + ids: (json['ids'] as List?) == null + ? null + : (json['ids'] as List?)!.map((e) => e as String).toList(), + isAutoRenewing: json['isAutoRenewing'] as bool, + isUpgradedIOS: json['isUpgradedIOS'] as bool?, + offerIOS: json['offerIOS'] != null + ? PurchaseOfferIOS.fromJson(json['offerIOS'] as Map) : null, - isAcknowledged: json['isAcknowledged'] as bool?, - autoRenewing: json['autoRenewing'] as bool?, - originalJson: json['originalJson'] as String?, - developerPayload: json['developerPayload'] as String?, - originalOrderId: json['originalOrderId'] as String?, - purchaseTime: json['purchaseTime'] as int?, - // Infer platform from JSON when available to allow tests on non-mobile hosts. - platform: (json['platform'] is String) - ? ((json['platform'] as String).toLowerCase() == 'android' - ? IapPlatform.android - : IapPlatform.ios) - : getCurrentPlatform(), - // iOS specific per OpenIAP spec - originalTransactionDateIOS: json['originalTransactionDateIOS'] as String?, + originalTransactionDateIOS: + (json['originalTransactionDateIOS'] as num?)?.toDouble(), originalTransactionIdentifierIOS: json['originalTransactionIdentifierIOS'] as String?, - isUpgradeIOS: json['isUpgradeIOS'] as bool?, - transactionStateIOS: json['transactionStateIOS'] != null - ? TransactionState.values[json['transactionStateIOS'] as int] - : null, - verificationResultIOS: json['verificationResultIOS'] != null - ? VerificationResult( - isVerified: json['verificationResultIOS']['isVerified'] as bool, - verificationError: - json['verificationResultIOS']['verificationError'] as String?, - data: _safeJsonMap(json['verificationResultIOS']['data']), - ) - : null, - environmentIOS: json['environmentIOS'] as String?, - expirationDateIOS: json['expirationDateIOS'] != null - ? DateTime.tryParse(json['expirationDateIOS'] as String) - : null, - revocationDateIOS: json['revocationDateIOS'] != null - ? DateTime.tryParse(json['revocationDateIOS'] as String) - : null, + ownershipTypeIOS: json['ownershipTypeIOS'] as String?, + platform: IapPlatform.fromJson(json['platform'] as String), + productId: json['productId'] as String, + purchaseState: PurchaseState.fromJson(json['purchaseState'] as String), + purchaseToken: json['purchaseToken'] as String?, + quantity: json['quantity'] as int, + quantityIOS: json['quantityIOS'] as int?, + reasonIOS: json['reasonIOS'] as String?, + reasonStringRepresentationIOS: + json['reasonStringRepresentationIOS'] as String?, + revocationDateIOS: (json['revocationDateIOS'] as num?)?.toDouble(), revocationReasonIOS: json['revocationReasonIOS'] as String?, - appAccountTokenIOS: json['appAccountTokenIOS'] as String?, - webOrderLineItemIdIOS: json['webOrderLineItemIdIOS'] as String?, + storefrontCountryCodeIOS: json['storefrontCountryCodeIOS'] as String?, subscriptionGroupIdIOS: json['subscriptionGroupIdIOS'] as String?, - isUpgradedIOS: json['isUpgradedIOS'] as bool?, - offerCodeRefNameIOS: json['offerCodeRefNameIOS'] as String?, - offerIdentifierIOS: json['offerIdentifierIOS'] as String?, - offerTypeIOS: json['offerTypeIOS'] as int?, - signedDateIOS: json['signedDateIOS'] as String?, - storeFrontIOS: json['storeFrontIOS'] as String?, - storeFrontCountryCodeIOS: json['storeFrontCountryCodeIOS'] as String?, - currencyCodeIOS: json['currencyCodeIOS'] as String?, - priceIOS: (json['priceIOS'] as num?)?.toDouble(), - jsonRepresentationIOS: json['jsonRepresentationIOS'] as String?, - isFinishedIOS: json['isFinishedIOS'] as bool?, - quantityIOS: json['quantityIOS'] as int?, - appBundleIdIOS: json['appBundleIdIOS'] as String?, - productTypeIOS: json['productTypeIOS'] as String?, - ownershipTypeIOS: json['ownershipTypeIOS'] as String?, + transactionDate: (json['transactionDate'] as num).toDouble(), transactionReasonIOS: json['transactionReasonIOS'] as String?, - reasonIOS: json['reasonIOS'] as String?, - offerIOS: json['offerIOS'] != null - ? Map.from(json['offerIOS'] as Map) + webOrderLineItemIdIOS: json['webOrderLineItemIdIOS'] as String?, + ); + } + + @override + Map toJson() { + return { + '__typename': 'PurchaseIOS', + 'appAccountToken': appAccountToken, + 'appBundleIdIOS': appBundleIdIOS, + 'countryCodeIOS': countryCodeIOS, + 'currencyCodeIOS': currencyCodeIOS, + 'currencySymbolIOS': currencySymbolIOS, + 'environmentIOS': environmentIOS, + 'expirationDateIOS': expirationDateIOS, + 'id': id, + 'ids': ids == null ? null : ids!.map((e) => e).toList(), + 'isAutoRenewing': isAutoRenewing, + 'isUpgradedIOS': isUpgradedIOS, + 'offerIOS': offerIOS?.toJson(), + 'originalTransactionDateIOS': originalTransactionDateIOS, + 'originalTransactionIdentifierIOS': originalTransactionIdentifierIOS, + 'ownershipTypeIOS': ownershipTypeIOS, + 'platform': platform.toJson(), + 'productId': productId, + 'purchaseState': purchaseState.toJson(), + 'purchaseToken': purchaseToken, + 'quantity': quantity, + 'quantityIOS': quantityIOS, + 'reasonIOS': reasonIOS, + 'reasonStringRepresentationIOS': reasonStringRepresentationIOS, + 'revocationDateIOS': revocationDateIOS, + 'revocationReasonIOS': revocationReasonIOS, + 'storefrontCountryCodeIOS': storefrontCountryCodeIOS, + 'subscriptionGroupIdIOS': subscriptionGroupIdIOS, + 'transactionDate': transactionDate, + 'transactionReasonIOS': transactionReasonIOS, + 'webOrderLineItemIdIOS': webOrderLineItemIdIOS, + }; + } +} + +class PurchaseOfferIOS { + const PurchaseOfferIOS({ + required this.id, + required this.paymentMode, + required this.type, + }); + + final String id; + final String paymentMode; + final String type; + + factory PurchaseOfferIOS.fromJson(Map json) { + return PurchaseOfferIOS( + id: json['id'] as String, + paymentMode: json['paymentMode'] as String, + type: json['type'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'PurchaseOfferIOS', + 'id': id, + 'paymentMode': paymentMode, + 'type': type, + }; + } +} + +class ReceiptValidationResultAndroid extends ReceiptValidationResult { + const ReceiptValidationResultAndroid({ + required this.autoRenewing, + required this.betaProduct, + this.cancelDate, + this.cancelReason, + this.deferredDate, + this.deferredSku, + required this.freeTrialEndDate, + required this.gracePeriodEndDate, + required this.parentProductId, + required this.productId, + required this.productType, + required this.purchaseDate, + required this.quantity, + required this.receiptId, + required this.renewalDate, + required this.term, + required this.termSku, + required this.testTransaction, + }); + + final bool autoRenewing; + final bool betaProduct; + final double? cancelDate; + final String? cancelReason; + final double? deferredDate; + final String? deferredSku; + final double freeTrialEndDate; + final double gracePeriodEndDate; + final String parentProductId; + final String productId; + final String productType; + final double purchaseDate; + final int quantity; + final String receiptId; + final double renewalDate; + final String term; + final String termSku; + final bool testTransaction; + + factory ReceiptValidationResultAndroid.fromJson(Map json) { + return ReceiptValidationResultAndroid( + autoRenewing: json['autoRenewing'] as bool, + betaProduct: json['betaProduct'] as bool, + cancelDate: (json['cancelDate'] as num?)?.toDouble(), + cancelReason: json['cancelReason'] as String?, + deferredDate: (json['deferredDate'] as num?)?.toDouble(), + deferredSku: json['deferredSku'] as String?, + freeTrialEndDate: (json['freeTrialEndDate'] as num).toDouble(), + gracePeriodEndDate: (json['gracePeriodEndDate'] as num).toDouble(), + parentProductId: json['parentProductId'] as String, + productId: json['productId'] as String, + productType: json['productType'] as String, + purchaseDate: (json['purchaseDate'] as num).toDouble(), + quantity: json['quantity'] as int, + receiptId: json['receiptId'] as String, + renewalDate: (json['renewalDate'] as num).toDouble(), + term: json['term'] as String, + termSku: json['termSku'] as String, + testTransaction: json['testTransaction'] as bool, + ); + } + + @override + Map toJson() { + return { + '__typename': 'ReceiptValidationResultAndroid', + 'autoRenewing': autoRenewing, + 'betaProduct': betaProduct, + 'cancelDate': cancelDate, + 'cancelReason': cancelReason, + 'deferredDate': deferredDate, + 'deferredSku': deferredSku, + 'freeTrialEndDate': freeTrialEndDate, + 'gracePeriodEndDate': gracePeriodEndDate, + 'parentProductId': parentProductId, + 'productId': productId, + 'productType': productType, + 'purchaseDate': purchaseDate, + 'quantity': quantity, + 'receiptId': receiptId, + 'renewalDate': renewalDate, + 'term': term, + 'termSku': termSku, + 'testTransaction': testTransaction, + }; + } +} + +class ReceiptValidationResultIOS extends ReceiptValidationResult { + const ReceiptValidationResultIOS({ + /// Whether the receipt is valid + required this.isValid, + + /// JWS representation + required this.jwsRepresentation, + + /// Latest transaction if available + this.latestTransaction, + + /// Receipt data string + required this.receiptData, + }); + + /// Whether the receipt is valid + final bool isValid; + + /// JWS representation + final String jwsRepresentation; + + /// Latest transaction if available + final Purchase? latestTransaction; + + /// Receipt data string + final String receiptData; + + factory ReceiptValidationResultIOS.fromJson(Map json) { + return ReceiptValidationResultIOS( + isValid: json['isValid'] as bool, + jwsRepresentation: json['jwsRepresentation'] as String, + latestTransaction: json['latestTransaction'] != null + ? Purchase.fromJson(json['latestTransaction'] as Map) : null, - jwsRepresentationIOS: json['jwsRepresentationIOS'] as String?, - // Android specific per OpenIAP spec - signatureAndroid: json['signatureAndroid'] as String?, - autoRenewingAndroid: json['autoRenewingAndroid'] as bool?, - orderIdAndroid: json['orderIdAndroid'] as String?, - packageNameAndroid: json['packageNameAndroid'] as String?, - developerPayloadAndroid: json['developerPayloadAndroid'] as String?, - acknowledgedAndroid: json['acknowledgedAndroid'] as bool?, - isAcknowledgedAndroid: json['isAcknowledgedAndroid'] as bool?, - purchaseStateAndroid: json['purchaseStateAndroid'] as int?, - purchaseTokenAndroid: json['purchaseTokenAndroid'] as String?, - dataAndroid: json['dataAndroid'] as String?, - obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?, - obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?, - originalJsonAndroid: json['originalJsonAndroid'] as String?, - productsAndroid: json['productsAndroid'] != null - ? (json['productsAndroid'] as List).cast() + receiptData: json['receiptData'] as String, + ); + } + + @override + Map toJson() { + return { + '__typename': 'ReceiptValidationResultIOS', + 'isValid': isValid, + 'jwsRepresentation': jwsRepresentation, + 'latestTransaction': latestTransaction?.toJson(), + 'receiptData': receiptData, + }; + } +} + +class RefundResultIOS { + const RefundResultIOS({ + this.message, + required this.status, + }); + + final String? message; + final String status; + + factory RefundResultIOS.fromJson(Map json) { + return RefundResultIOS( + message: json['message'] as String?, + status: json['status'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'RefundResultIOS', + 'message': message, + 'status': status, + }; + } +} + +class RenewalInfoIOS { + const RenewalInfoIOS({ + this.autoRenewPreference, + this.jsonRepresentation, + required this.willAutoRenew, + }); + + final String? autoRenewPreference; + final String? jsonRepresentation; + final bool willAutoRenew; + + factory RenewalInfoIOS.fromJson(Map json) { + return RenewalInfoIOS( + autoRenewPreference: json['autoRenewPreference'] as String?, + jsonRepresentation: json['jsonRepresentation'] as String?, + willAutoRenew: json['willAutoRenew'] as bool, + ); + } + + Map toJson() { + return { + '__typename': 'RenewalInfoIOS', + 'autoRenewPreference': autoRenewPreference, + 'jsonRepresentation': jsonRepresentation, + 'willAutoRenew': willAutoRenew, + }; + } +} + +class RequestPurchaseResult { + const RequestPurchaseResult({ + this.purchase, + this.purchases, + }); + + final Purchase? purchase; + final List? purchases; + + factory RequestPurchaseResult.fromJson(Map json) { + return RequestPurchaseResult( + purchase: json['purchase'] != null + ? Purchase.fromJson(json['purchase'] as Map) : null, - skusAndroid: json['skusAndroid'] != null - ? (json['skusAndroid'] as List).cast() + purchases: (json['purchases'] as List?) == null + ? null + : (json['purchases'] as List?)! + .map((e) => Purchase.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + '__typename': 'RequestPurchaseResult', + 'purchase': purchase?.toJson(), + 'purchases': + purchases == null ? null : purchases!.map((e) => e.toJson()).toList(), + }; + } +} + +class SubscriptionInfoIOS { + const SubscriptionInfoIOS({ + this.introductoryOffer, + this.promotionalOffers, + required this.subscriptionGroupId, + required this.subscriptionPeriod, + }); + + final SubscriptionOfferIOS? introductoryOffer; + final List? promotionalOffers; + final String subscriptionGroupId; + final SubscriptionPeriodValueIOS subscriptionPeriod; + + factory SubscriptionInfoIOS.fromJson(Map json) { + return SubscriptionInfoIOS( + introductoryOffer: json['introductoryOffer'] != null + ? SubscriptionOfferIOS.fromJson( + json['introductoryOffer'] as Map) : null, - isAutoRenewingAndroid: json['isAutoRenewingAndroid'] as bool?, - replacementTokenAndroid: json['replacementTokenAndroid'] as String?, - priceAmountMicrosAndroid: json['priceAmountMicrosAndroid'] as int?, - priceCurrencyCodeAndroid: json['priceCurrencyCodeAndroid'] as String?, - countryCodeAndroid: json['countryCodeAndroid'] as String?, - // ProductPurchase fields - isConsumedAndroid: json['isConsumedAndroid'] as bool?, + promotionalOffers: (json['promotionalOffers'] as List?) == null + ? null + : (json['promotionalOffers'] as List?)! + .map((e) => + SubscriptionOfferIOS.fromJson(e as Map)) + .toList(), + subscriptionGroupId: json['subscriptionGroupId'] as String, + subscriptionPeriod: SubscriptionPeriodValueIOS.fromJson( + json['subscriptionPeriod'] as Map), ); } - @override - String toString() { - // Helper function to truncate long strings - String? truncate(String? str, [int maxLength = 100]) { - if (str == null) return null; - if (str.length <= maxLength) return str; - return '${str.substring(0, maxLength)}... (${str.length} chars)'; - } + Map toJson() { + return { + '__typename': 'SubscriptionInfoIOS', + 'introductoryOffer': introductoryOffer?.toJson(), + 'promotionalOffers': promotionalOffers == null + ? null + : promotionalOffers!.map((e) => e.toJson()).toList(), + 'subscriptionGroupId': subscriptionGroupId, + 'subscriptionPeriod': subscriptionPeriod.toJson(), + }; + } +} - final buffer = StringBuffer('Purchase{\n'); - // Core fields - buffer.writeln(' productId: $productId,'); - buffer.writeln(' id: "$id",'); // Show as string with quotes - buffer.writeln( - ' transactionId: "$transactionId",', - ); // Show as string with quotes - buffer.writeln( - ' platform: \'${platform == IapPlatform.ios ? 'ios' : 'android'}\',', - ); // Show as string literal - buffer.writeln(' ids: $ids,'); // Show ids array - buffer.writeln(' purchaseToken: ${truncate(purchaseToken)},'); - buffer.writeln(' transactionReceipt: ${truncate(transactionReceipt)},'); - buffer.writeln(' orderId: $orderId,'); - buffer.writeln(' purchaseState: $purchaseState,'); - buffer.writeln(' isAcknowledged: $isAcknowledged,'); - buffer.writeln(' autoRenewing: $autoRenewing,'); - buffer.writeln(' transactionDate: $transactionDate,'); - - // iOS specific fields (only print non-null values for iOS platform) - if (platform == IapPlatform.ios) { - if (originalTransactionDateIOS != null) { - buffer.writeln( - ' originalTransactionDateIOS: $originalTransactionDateIOS,', - ); - } - if (originalTransactionIdentifierIOS != null) { - buffer.writeln( - ' originalTransactionIdentifierIOS: "$originalTransactionIdentifierIOS",', - ); // Show as string with quotes - } - if (transactionStateIOS != null) { - buffer.writeln(' transactionStateIOS: $transactionStateIOS,'); - } - if (quantityIOS != null) { - buffer.writeln(' quantityIOS: $quantityIOS,'); - } - if (expirationDateIOS != null) { - buffer.writeln( - ' expirationDateIOS: ${expirationDateIOS!.millisecondsSinceEpoch},', - ); - } - if (environmentIOS != null) { - buffer.writeln(' environmentIOS: "$environmentIOS",'); - } - if (subscriptionGroupIdIOS != null) { - buffer.writeln(' subscriptionGroupIdIOS: "$subscriptionGroupIdIOS",'); - } - if (productTypeIOS != null) { - buffer.writeln(' productTypeIOS: "$productTypeIOS",'); - } - if (transactionReasonIOS != null) { - buffer.writeln(' transactionReasonIOS: "$transactionReasonIOS",'); - } - if (currencyCodeIOS != null) { - buffer.writeln(' currencyCodeIOS: "$currencyCodeIOS",'); - } - if (storeFrontCountryCodeIOS != null) { - buffer.writeln( - ' storefrontCountryCodeIOS: "$storeFrontCountryCodeIOS",', - ); - } - if (appAccountTokenIOS != null) { - buffer.writeln(' appAccountTokenIOS: $appAccountTokenIOS,'); - } - if (appBundleIdIOS != null) { - buffer.writeln(' appBundleIdIOS: "$appBundleIdIOS",'); - } - if (productTypeIOS != null) { - buffer.writeln(' productTypeIOS: "$productTypeIOS",'); - } - if (subscriptionGroupIdIOS != null) { - buffer.writeln(' subscriptionGroupIdIOS: "$subscriptionGroupIdIOS",'); - } - if (isUpgradedIOS != null) { - buffer.writeln(' isUpgradedIOS: $isUpgradedIOS,'); - } - if (ownershipTypeIOS != null) { - buffer.writeln(' ownershipTypeIOS: "$ownershipTypeIOS",'); - } - if (webOrderLineItemIdIOS != null) { - buffer.writeln(' webOrderLineItemIdIOS: "$webOrderLineItemIdIOS",'); - } - if (storeFrontCountryCodeIOS != null) { - buffer.writeln( - ' storeFrontCountryCodeIOS: $storeFrontCountryCodeIOS,', - ); - } - if (reasonIOS != null) { - buffer.writeln(' reasonIOS: "$reasonIOS",'); - } - if (offerIOS != null) { - buffer.writeln(' offerIOS: $offerIOS,'); - } - if (priceIOS != null) { - buffer.writeln(' priceIOS: $priceIOS,'); - } - if (currencyCodeIOS != null) { - buffer.writeln(' currencyCodeIOS: "$currencyCodeIOS",'); - } - if (expirationDateIOS != null) { - buffer.writeln(' expirationDateIOS: $expirationDateIOS,'); - } - if (revocationDateIOS != null) { - buffer.writeln(' revocationDateIOS: $revocationDateIOS,'); - } - if (revocationReasonIOS != null) { - buffer.writeln(' revocationReasonIOS: $revocationReasonIOS,'); - } - if (jwsRepresentationIOS != null) { - buffer.writeln( - ' jwsRepresentationIOS: ${truncate(jwsRepresentationIOS)},', - ); - } - } +class SubscriptionOfferIOS { + const SubscriptionOfferIOS({ + required this.displayPrice, + required this.id, + required this.paymentMode, + required this.period, + required this.periodCount, + required this.price, + required this.type, + }); - // Android specific fields (only print non-null values for Android platform) - if (platform == IapPlatform.android) { - if (originalJsonAndroid != null) { - buffer.writeln( - ' originalJsonAndroid: ${truncate(originalJsonAndroid)},', - ); - } - if (signatureAndroid != null) { - buffer.writeln(' signatureAndroid: ${truncate(signatureAndroid)},'); - } - if (dataAndroid != null) { - buffer.writeln(' dataAndroid: ${truncate(dataAndroid)},'); - } - if (orderIdAndroid != null) { - buffer.writeln(' orderIdAndroid: $orderIdAndroid,'); - } - if (packageNameAndroid != null) { - buffer.writeln(' packageNameAndroid: $packageNameAndroid,'); - } - if (developerPayloadAndroid != null) { - buffer.writeln(' developerPayloadAndroid: $developerPayloadAndroid,'); - } - if (purchaseStateAndroid != null) { - buffer.writeln(' purchaseStateAndroid: $purchaseStateAndroid,'); - } - if (isAcknowledgedAndroid != null) { - buffer.writeln(' isAcknowledgedAndroid: $isAcknowledgedAndroid,'); - } - if (autoRenewingAndroid != null) { - buffer.writeln(' autoRenewingAndroid: $autoRenewingAndroid,'); - } - if (obfuscatedAccountIdAndroid != null) { - buffer.writeln( - ' obfuscatedAccountIdAndroid: $obfuscatedAccountIdAndroid,', - ); - } - if (obfuscatedProfileIdAndroid != null) { - buffer.writeln( - ' obfuscatedProfileIdAndroid: $obfuscatedProfileIdAndroid,', - ); - } - } + final String displayPrice; + final String id; + final PaymentModeIOS paymentMode; + final SubscriptionPeriodValueIOS period; + final int periodCount; + final double price; + final SubscriptionOfferTypeIOS type; - // Remove last comma if present - final str = buffer.toString(); - if (str.endsWith(',\n')) { - return '${str.substring(0, str.length - 2)}\n}'; - } - return '$str}'; + factory SubscriptionOfferIOS.fromJson(Map json) { + return SubscriptionOfferIOS( + displayPrice: json['displayPrice'] as String, + id: json['id'] as String, + paymentMode: PaymentModeIOS.fromJson(json['paymentMode'] as String), + period: SubscriptionPeriodValueIOS.fromJson( + json['period'] as Map), + periodCount: json['periodCount'] as int, + price: (json['price'] as num).toDouble(), + type: SubscriptionOfferTypeIOS.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'SubscriptionOfferIOS', + 'displayPrice': displayPrice, + 'id': id, + 'paymentMode': paymentMode.toJson(), + 'period': period.toJson(), + 'periodCount': periodCount, + 'price': price, + 'type': type.toJson(), + }; } } -// ============================================================================ -// New Platform-Specific Request Types (v2.7.0+) -// ============================================================================ - -/// iOS-specific purchase request parameters -class IosRequestPurchaseProps { - final String sku; - final bool? andDangerouslyFinishTransactionAutomaticallyIOS; - final String? appAccountToken; - final int? quantity; - final PaymentDiscount? withOffer; - - IosRequestPurchaseProps({ - required this.sku, - this.andDangerouslyFinishTransactionAutomaticallyIOS, - this.appAccountToken, - this.quantity, - this.withOffer, +class SubscriptionPeriodValueIOS { + const SubscriptionPeriodValueIOS({ + required this.unit, + required this.value, }); -} -/// Android-specific purchase request parameters (OpenIAP compliant) -class AndroidRequestPurchaseProps { - final List skus; - final String? obfuscatedAccountIdAndroid; - final String? obfuscatedProfileIdAndroid; - final bool? isOfferPersonalized; + final SubscriptionPeriodIOS unit; + final int value; - AndroidRequestPurchaseProps({ - required this.skus, - this.obfuscatedAccountIdAndroid, - this.obfuscatedProfileIdAndroid, - this.isOfferPersonalized, - }); + factory SubscriptionPeriodValueIOS.fromJson(Map json) { + return SubscriptionPeriodValueIOS( + unit: SubscriptionPeriodIOS.fromJson(json['unit'] as String), + value: json['value'] as int, + ); + } + + Map toJson() { + return { + '__typename': 'SubscriptionPeriodValueIOS', + 'unit': unit.toJson(), + 'value': value, + }; + } } -/// Android-specific subscription request parameters (OpenIAP compliant) -class AndroidRequestSubscriptionProps extends AndroidRequestPurchaseProps { - final String? purchaseTokenAndroid; - final int? replacementModeAndroid; - final List subscriptionOffers; - - AndroidRequestSubscriptionProps({ - required super.skus, - required this.subscriptionOffers, - super.obfuscatedAccountIdAndroid, - super.obfuscatedProfileIdAndroid, - super.isOfferPersonalized, - this.purchaseTokenAndroid, - this.replacementModeAndroid, +class SubscriptionStatusIOS { + const SubscriptionStatusIOS({ + this.renewalInfo, + required this.state, }); -} -/// Modern platform-specific request structure (v2.7.0+) -/// Allows clear separation of iOS and Android parameters -class PlatformRequestPurchaseProps { - final IosRequestPurchaseProps? ios; - final AndroidRequestPurchaseProps? android; + final RenewalInfoIOS? renewalInfo; + final String state; + + factory SubscriptionStatusIOS.fromJson(Map json) { + return SubscriptionStatusIOS( + renewalInfo: json['renewalInfo'] != null + ? RenewalInfoIOS.fromJson(json['renewalInfo'] as Map) + : null, + state: json['state'] as String, + ); + } - PlatformRequestPurchaseProps({this.ios, this.android}); + Map toJson() { + return { + '__typename': 'SubscriptionStatusIOS', + 'renewalInfo': renewalInfo?.toJson(), + 'state': state, + }; + } } -/// Modern platform-specific subscription request structure (v2.7.0+) -class PlatformRequestSubscriptionProps { - final IosRequestPurchaseProps? ios; - final AndroidRequestSubscriptionProps? android; +class VoidResult { + const VoidResult({ + required this.success, + }); - PlatformRequestSubscriptionProps({this.ios, this.android}); -} + final bool success; -/// Request purchase parameters -class RequestPurchase { - final RequestPurchaseIOS? ios; - final RequestPurchaseAndroid? android; + factory VoidResult.fromJson(Map json) { + return VoidResult( + success: json['success'] as bool, + ); + } - RequestPurchase({this.ios, this.android}); + Map toJson() { + return { + '__typename': 'VoidResult', + 'success': success, + }; + } } -/// Unified request properties for inapp purchases -class RequestPurchaseProps { - final String sku; - final bool? andDangerouslyFinishTransactionAutomaticallyIOS; - final String? appAccountToken; - final int? quantity; - final PaymentDiscount? withOffer; - final List? skus; - final String? obfuscatedAccountIdAndroid; - final String? obfuscatedProfileIdAndroid; - final bool? isOfferPersonalized; +// MARK: - Input Objects - RequestPurchaseProps({ +class AndroidSubscriptionOfferInput { + const AndroidSubscriptionOfferInput({ + /// Offer token + required this.offerToken, + + /// Product SKU required this.sku, - this.andDangerouslyFinishTransactionAutomaticallyIOS, - this.appAccountToken, - this.quantity, - this.withOffer, - this.skus, - this.obfuscatedAccountIdAndroid, - this.obfuscatedProfileIdAndroid, - this.isOfferPersonalized, }); + + /// Offer token + final String offerToken; + + /// Product SKU + final String sku; + + factory AndroidSubscriptionOfferInput.fromJson(Map json) { + return AndroidSubscriptionOfferInput( + offerToken: json['offerToken'] as String, + sku: json['sku'] as String, + ); + } + + Map toJson() { + return { + 'offerToken': offerToken, + 'sku': sku, + }; + } } -/// Unified request properties for subscriptions -class RequestSubscriptionProps extends RequestPurchaseProps { - final String? purchaseTokenAndroid; - final int? replacementModeAndroid; - final List? subscriptionOffers; - - RequestSubscriptionProps({ - required super.sku, - super.andDangerouslyFinishTransactionAutomaticallyIOS, - super.appAccountToken, - super.quantity, - super.withOffer, - super.skus, - super.obfuscatedAccountIdAndroid, - super.obfuscatedProfileIdAndroid, - super.isOfferPersonalized, - this.purchaseTokenAndroid, - this.replacementModeAndroid, - this.subscriptionOffers, +class DeepLinkOptions { + const DeepLinkOptions({ + /// Android package name to target (required on Android) + this.packageNameAndroid, + + /// Android SKU to open (required on Android) + this.skuAndroid, }); -} -/// Discriminated union for purchase requests -/// Following the TypeScript pattern: -/// type PurchaseRequest = -/// | { request: RequestPurchaseProps; type?: 'inapp'; } -/// | { request: RequestSubscriptionProps; type: 'subs'; } -class PurchaseRequest { - final dynamic request; - final String? type; + /// Android package name to target (required on Android) + final String? packageNameAndroid; - /// Constructor for in-app purchase (type is optional, defaults to 'inapp') - PurchaseRequest.inapp(RequestPurchaseProps props) - : request = props, - type = null; // type is optional for inapp + /// Android SKU to open (required on Android) + final String? skuAndroid; - /// Constructor for subscription (type is required and must be 'subs') - PurchaseRequest.subscription(RequestSubscriptionProps props) - : request = props, - type = 'subs'; + factory DeepLinkOptions.fromJson(Map json) { + return DeepLinkOptions( + packageNameAndroid: json['packageNameAndroid'] as String?, + skuAndroid: json['skuAndroid'] as String?, + ); + } - /// Check if this is a subscription purchase - bool get isSubscription => type == 'subs'; + Map toJson() { + return { + 'packageNameAndroid': packageNameAndroid, + 'skuAndroid': skuAndroid, + }; + } +} - /// Check if this is an in-app purchase - bool get isInapp => type == null || type == 'inapp'; +class DiscountOfferInputIOS { + const DiscountOfferInputIOS({ + /// Discount identifier + required this.identifier, - /// Get the request as RequestPurchaseProps if it's an in-app purchase - RequestPurchaseProps? get inappRequest => - isInapp ? request as RequestPurchaseProps : null; + /// Key identifier for validation + required this.keyIdentifier, - /// Get the request as RequestSubscriptionProps if it's a subscription - RequestSubscriptionProps? get subscriptionRequest => - isSubscription ? request as RequestSubscriptionProps : null; -} + /// Cryptographic nonce + required this.nonce, -/// iOS specific purchase request -class RequestPurchaseIOS { - final String sku; - final bool? andDangerouslyFinishTransactionAutomaticallyIOS; - final String? applicationUsername; - final String? appAccountToken; - final bool? simulatesAskToBuyInSandbox; - final String? discountIdentifier; - final String? discountTimestamp; - final String? discountNonce; - final String? discountSignature; - final int? quantity; - final PaymentDiscount? withOffer; + /// Signature for validation + required this.signature, - RequestPurchaseIOS({ - required this.sku, - this.andDangerouslyFinishTransactionAutomaticallyIOS, - this.applicationUsername, - this.appAccountToken, - this.simulatesAskToBuyInSandbox, - this.discountIdentifier, - this.discountTimestamp, - this.discountNonce, - this.discountSignature, - this.quantity, - this.withOffer, + /// Timestamp of discount offer + required this.timestamp, }); -} -/// Payment discount (iOS) -class PaymentDiscount { + /// Discount identifier final String identifier; + + /// Key identifier for validation final String keyIdentifier; + + /// Cryptographic nonce final String nonce; + + /// Signature for validation final String signature; - final String timestamp; - PaymentDiscount({ - required this.identifier, - required this.keyIdentifier, - required this.nonce, - required this.signature, - required this.timestamp, - }); + /// Timestamp of discount offer + final double timestamp; + + factory DiscountOfferInputIOS.fromJson(Map json) { + return DiscountOfferInputIOS( + identifier: json['identifier'] as String, + keyIdentifier: json['keyIdentifier'] as String, + nonce: json['nonce'] as String, + signature: json['signature'] as String, + timestamp: (json['timestamp'] as num).toDouble(), + ); + } - Map toMap() { + Map toJson() { return { 'identifier': identifier, 'keyIdentifier': keyIdentifier, @@ -2187,580 +2116,858 @@ class PaymentDiscount { 'timestamp': timestamp, }; } - - Map toJson() => toMap(); } -/// Android specific purchase request (OpenIAP compliant) -class RequestPurchaseAndroid { +class ProductRequest { + const ProductRequest({ + required this.skus, + this.type, + }); + final List skus; - final String? obfuscatedAccountIdAndroid; - final String? obfuscatedProfileIdAndroid; - final bool? isOfferPersonalized; + final ProductQueryType? type; - RequestPurchaseAndroid({ - required this.skus, + factory ProductRequest.fromJson(Map json) { + return ProductRequest( + skus: (json['skus'] as List).map((e) => e as String).toList(), + type: json['type'] != null + ? ProductQueryType.fromJson(json['type'] as String) + : null, + ); + } + + Map toJson() { + return { + 'skus': skus.map((e) => e).toList(), + 'type': type?.toJson(), + }; + } +} + +class PurchaseInput { + const PurchaseInput({ + required this.id, + this.ids, + required this.isAutoRenewing, + required this.platform, + required this.productId, + required this.purchaseState, + this.purchaseToken, + required this.quantity, + required this.transactionDate, + }); + + final String id; + final List? ids; + final bool isAutoRenewing; + final IapPlatform platform; + final String productId; + final PurchaseState purchaseState; + final String? purchaseToken; + final int quantity; + final double transactionDate; + + factory PurchaseInput.fromJson(Map json) { + return PurchaseInput( + id: json['id'] as String, + ids: (json['ids'] as List?) == null + ? null + : (json['ids'] as List?)!.map((e) => e as String).toList(), + isAutoRenewing: json['isAutoRenewing'] as bool, + platform: IapPlatform.fromJson(json['platform'] as String), + productId: json['productId'] as String, + purchaseState: PurchaseState.fromJson(json['purchaseState'] as String), + purchaseToken: json['purchaseToken'] as String?, + quantity: json['quantity'] as int, + transactionDate: (json['transactionDate'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'id': id, + 'ids': ids == null ? null : ids!.map((e) => e).toList(), + 'isAutoRenewing': isAutoRenewing, + 'platform': platform.toJson(), + 'productId': productId, + 'purchaseState': purchaseState.toJson(), + 'purchaseToken': purchaseToken, + 'quantity': quantity, + 'transactionDate': transactionDate, + }; + } +} + +class PurchaseOptions { + const PurchaseOptions({ + /// Also emit results through the iOS event listeners + this.alsoPublishToEventListenerIOS, + + /// Limit to currently active items on iOS + this.onlyIncludeActiveItemsIOS, + }); + + /// Also emit results through the iOS event listeners + final bool? alsoPublishToEventListenerIOS; + + /// Limit to currently active items on iOS + final bool? onlyIncludeActiveItemsIOS; + + factory PurchaseOptions.fromJson(Map json) { + return PurchaseOptions( + alsoPublishToEventListenerIOS: + json['alsoPublishToEventListenerIOS'] as bool?, + onlyIncludeActiveItemsIOS: json['onlyIncludeActiveItemsIOS'] as bool?, + ); + } + + Map toJson() { + return { + 'alsoPublishToEventListenerIOS': alsoPublishToEventListenerIOS, + 'onlyIncludeActiveItemsIOS': onlyIncludeActiveItemsIOS, + }; + } +} + +class ReceiptValidationAndroidOptions { + const ReceiptValidationAndroidOptions({ + required this.accessToken, + this.isSub, + required this.packageName, + required this.productToken, + }); + + final String accessToken; + final bool? isSub; + final String packageName; + final String productToken; + + factory ReceiptValidationAndroidOptions.fromJson(Map json) { + return ReceiptValidationAndroidOptions( + accessToken: json['accessToken'] as String, + isSub: json['isSub'] as bool?, + packageName: json['packageName'] as String, + productToken: json['productToken'] as String, + ); + } + + Map toJson() { + return { + 'accessToken': accessToken, + 'isSub': isSub, + 'packageName': packageName, + 'productToken': productToken, + }; + } +} + +class ReceiptValidationProps { + const ReceiptValidationProps({ + /// Android-specific validation options + this.androidOptions, + + /// Product SKU to validate + required this.sku, + }); + + /// Android-specific validation options + final ReceiptValidationAndroidOptions? androidOptions; + + /// Product SKU to validate + final String sku; + + factory ReceiptValidationProps.fromJson(Map json) { + return ReceiptValidationProps( + androidOptions: json['androidOptions'] != null + ? ReceiptValidationAndroidOptions.fromJson( + json['androidOptions'] as Map) + : null, + sku: json['sku'] as String, + ); + } + + Map toJson() { + return { + 'androidOptions': androidOptions?.toJson(), + 'sku': sku, + }; + } +} + +class RequestPurchaseAndroidProps { + const RequestPurchaseAndroidProps({ + /// Personalized offer flag + this.isOfferPersonalized, + + /// Obfuscated account ID this.obfuscatedAccountIdAndroid, + + /// Obfuscated profile ID this.obfuscatedProfileIdAndroid, - this.isOfferPersonalized, + + /// List of product SKUs + required this.skus, }); - /// Convenience getter for single SKU - String get sku => skus.isNotEmpty ? skus.first : ''; -} - -/// Android specific subscription request (OpenIAP compliant) -/// -/// When upgrading/downgrading a subscription (using replacementModeAndroid), -/// you MUST provide the purchaseTokenAndroid from the existing subscription. -/// -/// Example: -/// ```dart -/// // Get existing subscription's purchase token -/// final purchases = await FlutterInappPurchase.instance.getAvailablePurchases(); -/// final existingSubscription = purchases.firstWhere((p) => p.productId == 'current_subscription'); -/// -/// // Upgrade/downgrade with proration mode -/// await FlutterInappPurchase.instance.requestPurchase( -/// request: RequestPurchase( -/// android: RequestSubscriptionAndroid( -/// skus: ['new_subscription_id'], -/// purchaseTokenAndroid: existingSubscription.purchaseToken, // Required! -/// replacementModeAndroid: AndroidReplacementMode.deferred.value, -/// subscriptionOffers: [...], -/// ), -/// ), -/// type: ProductType.subs, -/// ); -/// ``` -class RequestSubscriptionAndroid extends RequestPurchaseAndroid { - /// The purchase token from the existing subscription that is being replaced. - /// REQUIRED when using replacementModeAndroid (replacement mode). - final String? purchaseTokenAndroid; + /// Personalized offer flag + final bool? isOfferPersonalized; - /// The replacement mode for subscription replacement. - /// When set, purchaseTokenAndroid MUST be provided. - /// Use values from AndroidReplacementMode class. - final int? replacementModeAndroid; + /// Obfuscated account ID + final String? obfuscatedAccountIdAndroid; - final List subscriptionOffers; + /// Obfuscated profile ID + final String? obfuscatedProfileIdAndroid; - RequestSubscriptionAndroid({ - required super.skus, - required this.subscriptionOffers, - super.obfuscatedAccountIdAndroid, - super.obfuscatedProfileIdAndroid, - super.isOfferPersonalized, - this.purchaseTokenAndroid, - this.replacementModeAndroid, - }) { - // Add assertion for development time validation - assert( - replacementModeAndroid == null || - replacementModeAndroid == -1 || - (purchaseTokenAndroid != null && purchaseTokenAndroid!.isNotEmpty), - 'purchaseTokenAndroid is required when using replacementModeAndroid (replacement mode)', + /// List of product SKUs + final List skus; + + factory RequestPurchaseAndroidProps.fromJson(Map json) { + return RequestPurchaseAndroidProps( + isOfferPersonalized: json['isOfferPersonalized'] as bool?, + obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?, + obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?, + skus: (json['skus'] as List).map((e) => e as String).toList(), ); } -} -/// Subscription offer for Android -class SubscriptionOfferAndroid { - final String sku; - final String offerToken; + Map toJson() { + return { + 'isOfferPersonalized': isOfferPersonalized, + 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid, + 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid, + 'skus': skus.map((e) => e).toList(), + }; + } +} - SubscriptionOfferAndroid({required this.sku, required this.offerToken}); +class RequestPurchaseIosProps { + const RequestPurchaseIosProps({ + /// Auto-finish transaction (dangerous) + this.andDangerouslyFinishTransactionAutomatically, - SubscriptionOfferAndroid.fromJSON(Map json) - : sku = json['sku'] as String, - offerToken = json['offerToken'] as String; + /// App account token for user tracking + this.appAccountToken, - SubscriptionOfferAndroid.fromJson(Map json) - : sku = json['sku'] as String? ?? '', - offerToken = json['offerToken'] as String? ?? ''; + /// Purchase quantity + this.quantity, - Map toJson() => {'sku': sku, 'offerToken': offerToken}; + /// Product SKU + required this.sku, - @override - String toString() { - return 'SubscriptionOfferAndroid{sku: $sku, offerToken: $offerToken}'; - } + /// Discount offer to apply + this.withOffer, + }); - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; + /// Auto-finish transaction (dangerous) + final bool? andDangerouslyFinishTransactionAutomatically; - return other is SubscriptionOfferAndroid && - other.sku == sku && - other.offerToken == offerToken; - } + /// App account token for user tracking + final String? appAccountToken; - @override - int get hashCode => sku.hashCode ^ offerToken.hashCode; -} + /// Purchase quantity + final int? quantity; -/// Request subscription parameters -class RequestSubscription { + /// Product SKU final String sku; - final bool? andDangerouslyFinishTransactionAutomaticallyIOS; - - RequestSubscription({ - required this.sku, - this.andDangerouslyFinishTransactionAutomaticallyIOS, - }); -} -/// Unified request purchase props -class UnifiedRequestPurchaseProps { - final String productId; - final bool? autoFinishTransaction; - final String? accountId; - final String? profileId; - final String? applicationUsername; - final bool? simulatesAskToBuyInSandbox; - final PaymentDiscount? paymentDiscount; - final Map? additionalOptions; - - UnifiedRequestPurchaseProps({ - required this.productId, - this.autoFinishTransaction, - this.accountId, - this.profileId, - this.applicationUsername, - this.simulatesAskToBuyInSandbox, - this.paymentDiscount, - this.additionalOptions, - }); + /// Discount offer to apply + final DiscountOfferInputIOS? withOffer; + + factory RequestPurchaseIosProps.fromJson(Map json) { + return RequestPurchaseIosProps( + andDangerouslyFinishTransactionAutomatically: + json['andDangerouslyFinishTransactionAutomatically'] as bool?, + appAccountToken: json['appAccountToken'] as String?, + quantity: json['quantity'] as int?, + sku: json['sku'] as String, + withOffer: json['withOffer'] != null + ? DiscountOfferInputIOS.fromJson( + json['withOffer'] as Map) + : null, + ); + } - Map toMap() { + Map toJson() { return { - 'productId': productId, - if (autoFinishTransaction != null) - 'autoFinishTransaction': autoFinishTransaction, - if (accountId != null) 'accountId': accountId, - if (profileId != null) 'profileId': profileId, - if (applicationUsername != null) - 'applicationUsername': applicationUsername, - if (simulatesAskToBuyInSandbox != null) - 'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox, - if (paymentDiscount != null) 'paymentDiscount': paymentDiscount!.toMap(), - if (additionalOptions != null) ...additionalOptions!, + 'andDangerouslyFinishTransactionAutomatically': + andDangerouslyFinishTransactionAutomatically, + 'appAccountToken': appAccountToken, + 'quantity': quantity, + 'sku': sku, + 'withOffer': withOffer?.toJson(), }; } } -/// Unified subscription request props -class UnifiedRequestSubscriptionProps extends UnifiedRequestPurchaseProps { - final String? offerToken; - final List? offerTokens; - final String? replacementMode; - final String? replacementProductId; - final String? replacementPurchaseToken; - final int? replacementModeAndroid; - - UnifiedRequestSubscriptionProps({ - required super.productId, - super.autoFinishTransaction, - super.accountId, - super.profileId, - super.applicationUsername, - super.simulatesAskToBuyInSandbox, - super.paymentDiscount, - super.additionalOptions, - this.offerToken, - this.offerTokens, - this.replacementMode, - this.replacementProductId, - this.replacementPurchaseToken, - this.replacementModeAndroid, - }); - - @override - Map toMap() { - final map = super.toMap(); - if (offerToken != null) map['offerToken'] = offerToken; - if (offerTokens != null) map['offerTokens'] = offerTokens; - if (replacementMode != null) map['replacementMode'] = replacementMode; - if (replacementProductId != null) { - map['replacementProductId'] = replacementProductId; - } - if (replacementPurchaseToken != null) { - map['replacementPurchaseToken'] = replacementPurchaseToken; +class RequestPurchaseProps { + RequestPurchaseProps({ + required this.request, + ProductQueryType? type, + }) : type = type ?? + (request is RequestPurchasePropsRequestPurchase + ? ProductQueryType.InApp + : ProductQueryType.Subs) { + if (request is RequestPurchasePropsRequestPurchase && + this.type != ProductQueryType.InApp) { + throw ArgumentError( + 'type must be IN_APP when requestPurchase is provided'); } - if (replacementModeAndroid != null) { - map['replacementMode'] = replacementModeAndroid; + if (request is RequestPurchasePropsRequestSubscription && + this.type != ProductQueryType.Subs) { + throw ArgumentError( + 'type must be SUBS when requestSubscription is provided'); } - return map; } -} -/// Request products parameters + final RequestPurchasePropsRequest request; + final ProductQueryType type; + + factory RequestPurchaseProps.fromJson(Map json) { + final typeValue = json['type'] as String?; + final parsedType = + typeValue != null ? ProductQueryType.fromJson(typeValue) : null; + final purchaseJson = json['requestPurchase'] as Map?; + if (purchaseJson != null) { + final request = RequestPurchasePropsRequestPurchase( + RequestPurchasePropsByPlatforms.fromJson(purchaseJson)); + final finalType = parsedType ?? ProductQueryType.InApp; + if (finalType != ProductQueryType.InApp) { + throw ArgumentError( + 'type must be IN_APP when requestPurchase is provided'); + } + return RequestPurchaseProps(request: request, type: finalType); + } + final subscriptionJson = + json['requestSubscription'] as Map?; + if (subscriptionJson != null) { + final request = RequestPurchasePropsRequestSubscription( + RequestSubscriptionPropsByPlatforms.fromJson(subscriptionJson)); + final finalType = parsedType ?? ProductQueryType.Subs; + if (finalType != ProductQueryType.Subs) { + throw ArgumentError( + 'type must be SUBS when requestSubscription is provided'); + } + return RequestPurchaseProps(request: request, type: finalType); + } + throw ArgumentError( + 'RequestPurchaseProps requires requestPurchase or requestSubscription'); + } -/// Unified purchase request (OpenIAP compliant) -class UnifiedPurchaseRequest { - final String productId; - final IOSPurchaseOptions? iosOptions; - final AndroidPurchaseOptions? androidOptions; - final ValidationOptions? validationOptions; - final DeepLinkOptions? deepLinkOptions; + Map toJson() { + if (request is RequestPurchasePropsRequestPurchase) { + return { + 'requestPurchase': + (request as RequestPurchasePropsRequestPurchase).value.toJson(), + 'type': type.toJson(), + }; + } + if (request is RequestPurchasePropsRequestSubscription) { + return { + 'requestSubscription': + (request as RequestPurchasePropsRequestSubscription).value.toJson(), + 'type': type.toJson(), + }; + } + throw StateError('Unsupported RequestPurchaseProps request variant'); + } - UnifiedPurchaseRequest({ - required this.productId, - this.iosOptions, - this.androidOptions, - this.validationOptions, - this.deepLinkOptions, - }); + static RequestPurchaseProps inApp( + {required RequestPurchasePropsByPlatforms request}) { + return RequestPurchaseProps( + request: RequestPurchasePropsRequestPurchase(request), + type: ProductQueryType.InApp); + } - Map toMap() { - return { - 'productId': productId, - if (iosOptions != null) 'iosOptions': iosOptions!.toMap(), - if (androidOptions != null) 'androidOptions': androidOptions!.toMap(), - if (validationOptions != null) - 'validationOptions': validationOptions!.toMap(), - if (deepLinkOptions != null) 'deepLinkOptions': deepLinkOptions!.toMap(), - }; + static RequestPurchaseProps subs( + {required RequestSubscriptionPropsByPlatforms request}) { + return RequestPurchaseProps( + request: RequestPurchasePropsRequestSubscription(request), + type: ProductQueryType.Subs); } } -/// Platform purchase request (OpenIAP compliant) -class PlatformPurchaseRequest { - final String productId; - final Map options; +sealed class RequestPurchasePropsRequest { + const RequestPurchasePropsRequest(); +} + +class RequestPurchasePropsRequestPurchase extends RequestPurchasePropsRequest { + const RequestPurchasePropsRequestPurchase(this.value); + final RequestPurchasePropsByPlatforms value; +} - PlatformPurchaseRequest({required this.productId, required this.options}); +class RequestPurchasePropsRequestSubscription + extends RequestPurchasePropsRequest { + const RequestPurchasePropsRequestSubscription(this.value); + final RequestSubscriptionPropsByPlatforms value; } -/// iOS purchase options (OpenIAP compliant) -class IOSPurchaseOptions { - final bool? autoFinishTransaction; - final String? applicationUsername; - final bool? simulatesAskToBuyInSandbox; - final PaymentDiscount? paymentDiscount; +class RequestPurchasePropsByPlatforms { + const RequestPurchasePropsByPlatforms({ + /// Android-specific purchase parameters + this.android, - IOSPurchaseOptions({ - this.autoFinishTransaction, - this.applicationUsername, - this.simulatesAskToBuyInSandbox, - this.paymentDiscount, + /// iOS-specific purchase parameters + this.ios, }); - Map toMap() { + /// Android-specific purchase parameters + final RequestPurchaseAndroidProps? android; + + /// iOS-specific purchase parameters + final RequestPurchaseIosProps? ios; + + factory RequestPurchasePropsByPlatforms.fromJson(Map json) { + return RequestPurchasePropsByPlatforms( + android: json['android'] != null + ? RequestPurchaseAndroidProps.fromJson( + json['android'] as Map) + : null, + ios: json['ios'] != null + ? RequestPurchaseIosProps.fromJson( + json['ios'] as Map) + : null, + ); + } + + Map toJson() { return { - if (autoFinishTransaction != null) - 'autoFinishTransaction': autoFinishTransaction, - if (applicationUsername != null) - 'applicationUsername': applicationUsername, - if (simulatesAskToBuyInSandbox != null) - 'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox, - if (paymentDiscount != null) 'paymentDiscount': paymentDiscount!.toMap(), + 'android': android?.toJson(), + 'ios': ios?.toJson(), }; } } -/// Android purchase options (OpenIAP compliant) -class AndroidPurchaseOptions { - final String? accountId; - final String? profileId; - final String? offerToken; - final List? offerTokens; - final ReplacementMode? replacementMode; - final String? replacementProductId; - final String? replacementPurchaseToken; - final int? replacementModeAndroid; +class RequestSubscriptionAndroidProps { + const RequestSubscriptionAndroidProps({ + /// Personalized offer flag + this.isOfferPersonalized, + + /// Obfuscated account ID + this.obfuscatedAccountIdAndroid, + + /// Obfuscated profile ID + this.obfuscatedProfileIdAndroid, + + /// Purchase token for upgrades/downgrades + this.purchaseTokenAndroid, - AndroidPurchaseOptions({ - this.accountId, - this.profileId, - this.offerToken, - this.offerTokens, - this.replacementMode, - this.replacementProductId, - this.replacementPurchaseToken, + /// Replacement mode for subscription changes this.replacementModeAndroid, + + /// List of subscription SKUs + required this.skus, + + /// Subscription offers + this.subscriptionOffers, }); - Map toMap() { - return { - if (accountId != null) 'accountId': accountId, - if (profileId != null) 'profileId': profileId, - if (offerToken != null) 'offerToken': offerToken, - if (offerTokens != null) 'offerTokens': offerTokens, - if (replacementMode != null) - 'replacementMode': replacementMode.toString().split('.').last, - if (replacementProductId != null) - 'replacementProductId': replacementProductId, - if (replacementPurchaseToken != null) - 'replacementPurchaseToken': replacementPurchaseToken, - if (replacementModeAndroid != null) - 'replacementMode': replacementModeAndroid, - }; - } -} + /// Personalized offer flag + final bool? isOfferPersonalized; -/// Validation options (OpenIAP compliant) -class ValidationOptions { - final bool? validateOnPurchase; - final String? validationUrl; - final Map? headers; - final IOSReceiptBody? iosReceiptBody; + /// Obfuscated account ID + final String? obfuscatedAccountIdAndroid; - ValidationOptions({ - this.validateOnPurchase, - this.validationUrl, - this.headers, - this.iosReceiptBody, - }); + /// Obfuscated profile ID + final String? obfuscatedProfileIdAndroid; + + /// Purchase token for upgrades/downgrades + final String? purchaseTokenAndroid; + + /// Replacement mode for subscription changes + final int? replacementModeAndroid; + + /// List of subscription SKUs + final List skus; + + /// Subscription offers + final List? subscriptionOffers; + + factory RequestSubscriptionAndroidProps.fromJson(Map json) { + return RequestSubscriptionAndroidProps( + isOfferPersonalized: json['isOfferPersonalized'] as bool?, + obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?, + obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?, + purchaseTokenAndroid: json['purchaseTokenAndroid'] as String?, + replacementModeAndroid: json['replacementModeAndroid'] as int?, + skus: (json['skus'] as List).map((e) => e as String).toList(), + subscriptionOffers: (json['subscriptionOffers'] as List?) == null + ? null + : (json['subscriptionOffers'] as List?)! + .map((e) => AndroidSubscriptionOfferInput.fromJson( + e as Map)) + .toList(), + ); + } - Map toMap() { + Map toJson() { return { - if (validateOnPurchase != null) 'validateOnPurchase': validateOnPurchase, - if (validationUrl != null) 'validationUrl': validationUrl, - if (headers != null) 'headers': headers, - if (iosReceiptBody != null) 'iosReceiptBody': iosReceiptBody!.toMap(), + 'isOfferPersonalized': isOfferPersonalized, + 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid, + 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid, + 'purchaseTokenAndroid': purchaseTokenAndroid, + 'replacementModeAndroid': replacementModeAndroid, + 'skus': skus.map((e) => e).toList(), + 'subscriptionOffers': subscriptionOffers == null + ? null + : subscriptionOffers!.map((e) => e.toJson()).toList(), }; } } -/// iOS receipt body (OpenIAP compliant) -class IOSReceiptBody { - final String? password; - final bool? excludeOldTransactions; +class RequestSubscriptionIosProps { + const RequestSubscriptionIosProps({ + this.andDangerouslyFinishTransactionAutomatically, + this.appAccountToken, + this.quantity, + required this.sku, + this.withOffer, + }); - IOSReceiptBody({this.password, this.excludeOldTransactions}); + final bool? andDangerouslyFinishTransactionAutomatically; + final String? appAccountToken; + final int? quantity; + final String sku; + final DiscountOfferInputIOS? withOffer; + + factory RequestSubscriptionIosProps.fromJson(Map json) { + return RequestSubscriptionIosProps( + andDangerouslyFinishTransactionAutomatically: + json['andDangerouslyFinishTransactionAutomatically'] as bool?, + appAccountToken: json['appAccountToken'] as String?, + quantity: json['quantity'] as int?, + sku: json['sku'] as String, + withOffer: json['withOffer'] != null + ? DiscountOfferInputIOS.fromJson( + json['withOffer'] as Map) + : null, + ); + } - Map toMap() { + Map toJson() { return { - if (password != null) 'password': password, - if (excludeOldTransactions != null) - 'excludeOldTransactions': excludeOldTransactions, + 'andDangerouslyFinishTransactionAutomatically': + andDangerouslyFinishTransactionAutomatically, + 'appAccountToken': appAccountToken, + 'quantity': quantity, + 'sku': sku, + 'withOffer': withOffer?.toJson(), }; } } -/// Validation result (OpenIAP compliant) -class ValidationResult { - final bool isValid; - final String? errorMessage; - final Map? receipt; - final Map? parsedReceipt; - final String? originalResponse; +class RequestSubscriptionPropsByPlatforms { + const RequestSubscriptionPropsByPlatforms({ + /// Android-specific subscription parameters + this.android, - ValidationResult({ - required this.isValid, - this.errorMessage, - this.receipt, - this.parsedReceipt, - this.originalResponse, + /// iOS-specific subscription parameters + this.ios, }); - factory ValidationResult.fromJson(Map json) { - return ValidationResult( - isValid: json['isValid'] as bool? ?? false, - errorMessage: json['errorMessage'] as String?, - receipt: json['receipt'] != null - ? Map.from(json['receipt'] as Map) + /// Android-specific subscription parameters + final RequestSubscriptionAndroidProps? android; + + /// iOS-specific subscription parameters + final RequestSubscriptionIosProps? ios; + + factory RequestSubscriptionPropsByPlatforms.fromJson( + Map json) { + return RequestSubscriptionPropsByPlatforms( + android: json['android'] != null + ? RequestSubscriptionAndroidProps.fromJson( + json['android'] as Map) : null, - parsedReceipt: json['parsedReceipt'] != null - ? Map.from(json['parsedReceipt'] as Map) + ios: json['ios'] != null + ? RequestSubscriptionIosProps.fromJson( + json['ios'] as Map) : null, - originalResponse: json['originalResponse'] as String?, ); } Map toJson() { return { - 'isValid': isValid, - if (errorMessage != null) 'errorMessage': errorMessage, - if (receipt != null) 'receipt': receipt, - if (parsedReceipt != null) 'parsedReceipt': parsedReceipt, - if (originalResponse != null) 'originalResponse': originalResponse, + 'android': android?.toJson(), + 'ios': ios?.toJson(), }; } +} - @override - String toString() { - return 'ValidationResult{isValid: $isValid, errorMessage: $errorMessage}'; +// MARK: - Unions + +sealed class Product implements ProductCommon { + const Product(); + + factory Product.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'ProductAndroid': + return ProductAndroid.fromJson(json); + case 'ProductIOS': + return ProductIOS.fromJson(json); + } + throw ArgumentError('Unknown __typename for Product: $typeName'); } + + @override + String get currency; + @override + String? get debugDescription; + @override + String get description; + @override + String? get displayName; + @override + String get displayPrice; + @override + String get id; + @override + IapPlatform get platform; + @override + double? get price; + @override + String get title; + @override + ProductType get type; + + Map toJson(); } -// ReplacementMode enum is defined in enums.dart to avoid duplication +sealed class ProductSubscription implements ProductCommon { + const ProductSubscription(); -/// Receipt validation properties (OpenIAP compliant) -class ReceiptValidationProps { - /// Product SKU to validate (required for both iOS and Android) - final String sku; + factory ProductSubscription.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'ProductSubscriptionAndroid': + return ProductSubscriptionAndroid.fromJson(json); + case 'ProductSubscriptionIOS': + return ProductSubscriptionIOS.fromJson(json); + } + throw ArgumentError( + 'Unknown __typename for ProductSubscription: $typeName'); + } - /// Android-specific validation options - final AndroidValidationOptions? androidOptions; + @override + String get currency; + @override + String? get debugDescription; + @override + String get description; + @override + String? get displayName; + @override + String get displayPrice; + @override + String get id; + @override + IapPlatform get platform; + @override + double? get price; + @override + String get title; + @override + ProductType get type; - ReceiptValidationProps({required this.sku, this.androidOptions}); + Map toJson(); +} - Map toJson() { - return { - 'productId': sku, // Use productId for backward compatibility - 'sku': sku, - if (androidOptions != null) 'androidOptions': androidOptions!.toJson(), - }; +sealed class Purchase implements PurchaseCommon { + const Purchase(); + + factory Purchase.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'PurchaseAndroid': + return PurchaseAndroid.fromJson(json); + case 'PurchaseIOS': + return PurchaseIOS.fromJson(json); + } + throw ArgumentError('Unknown __typename for Purchase: $typeName'); } - factory ReceiptValidationProps.fromJson(Map json) { - final sku = json['sku'] as String? ?? json['productId'] as String?; - if (sku == null) { - throw ArgumentError('Either sku or productId must be provided'); + @override + String get id; + @override + List? get ids; + @override + bool get isAutoRenewing; + @override + IapPlatform get platform; + @override + String get productId; + @override + PurchaseState get purchaseState; + + /// Unified purchase token (iOS JWS, Android purchaseToken) + @override + String? get purchaseToken; + @override + int get quantity; + @override + double get transactionDate; + + Map toJson(); +} + +sealed class ReceiptValidationResult { + const ReceiptValidationResult(); + + factory ReceiptValidationResult.fromJson(Map json) { + final typeName = json['__typename'] as String?; + switch (typeName) { + case 'ReceiptValidationResultAndroid': + return ReceiptValidationResultAndroid.fromJson(json); + case 'ReceiptValidationResultIOS': + return ReceiptValidationResultIOS.fromJson(json); } - return ReceiptValidationProps( - sku: sku, - androidOptions: json['androidOptions'] != null - ? AndroidValidationOptions.fromJson( - Map.from(json['androidOptions'] as Map), - ) - : null, - ); + throw ArgumentError( + 'Unknown __typename for ReceiptValidationResult: $typeName'); } + + Map toJson(); } -/// Android-specific validation options for receipt validation -class AndroidValidationOptions { - /// Package name of your Android app - final String packageName; +// MARK: - Root Operations - /// Purchase token for validation (from the purchase) - final String productToken; +/// GraphQL root mutation operations. +abstract class MutationResolver { + /// Acknowledge a non-consumable purchase or subscription + Future acknowledgePurchaseAndroid({ + required String purchaseToken, + }); - /// OAuth access token with androidpublisher scope - /// WARNING: Including this in production builds is dangerous! - final String accessToken; + /// Initiate a refund request for a product (iOS 15+) + Future beginRefundRequestIOS({ + required String sku, + }); - /// Whether this is a subscription (true) or in-app product (false) - final bool isSub; + /// Clear pending transactions from the StoreKit payment queue + Future clearTransactionIOS(); - AndroidValidationOptions({ - required this.packageName, - required this.productToken, - required this.accessToken, - this.isSub = false, + /// Consume a purchase token so it can be repurchased + Future consumePurchaseAndroid({ + required String purchaseToken, }); - Map toJson() { - return { - 'packageName': packageName, - 'productToken': productToken, - // Do not include accessToken in toJson for security reasons - // accessToken should only be used server-side - 'isSub': isSub, - }; - } + /// Open the native subscription management surface + Future deepLinkToSubscriptions({ + DeepLinkOptions? options, + }); - factory AndroidValidationOptions.fromJson(Map json) { - return AndroidValidationOptions( - packageName: json['packageName'] as String, - productToken: json['productToken'] as String, - accessToken: json['accessToken'] as String, - isSub: json['isSub'] as bool? ?? false, - ); - } + /// Close the platform billing connection + Future endConnection(); + + /// Finish a transaction after validating receipts + Future finishTransaction({ + required PurchaseInput purchase, + bool? isConsumable, + }); + + /// Establish the platform billing connection + Future initConnection(); + + /// Present the App Store code redemption sheet + Future presentCodeRedemptionSheetIOS(); + + /// Initiate a purchase flow; rely on events for final state + Future requestPurchase({ + required RequestPurchaseProps params, + }); + + /// Purchase the promoted product surfaced by the App Store + Future requestPurchaseOnPromotedProductIOS(); + + /// Restore completed purchases across platforms + Future restorePurchases(); + + /// Open subscription management UI and return changed purchases (iOS 15+) + Future> showManageSubscriptionsIOS(); + + /// Force a StoreKit sync for transactions (iOS 15+) + Future syncIOS(); + + /// Validate purchase receipts with the configured providers + Future validateReceipt({ + required ReceiptValidationProps options, + }); } -/// Receipt validation result (OpenIAP compliant) -class ReceiptValidationResult { - /// Whether the receipt is valid - final bool isValid; +/// GraphQL root query operations. +abstract class QueryResolver { + /// Get current StoreKit 2 entitlements (iOS 15+) + Future> currentEntitlementIOS({ + List? skus, + }); + + /// Retrieve products or subscriptions from the store + Future fetchProducts({ + required ProductRequest params, + }); - /// Error message if validation failed - final String? errorMessage; + /// Get active subscriptions (filters by subscriptionIds when provided) + Future> getActiveSubscriptions({ + List? subscriptionIds, + }); - /// Base64 encoded receipt data (iOS legacy) - final String? receiptData; + /// Fetch the current app transaction (iOS 16+) + Future getAppTransactionIOS(); - /// Unified purchase token field (JWS for iOS, purchase token for Android) - final String? purchaseToken; + /// Get all available purchases for the current user + Future> getAvailablePurchases({ + PurchaseOptions? options, + }); - /// JWS representation (iOS StoreKit 2) - DEPRECATED: Use purchaseToken instead - @Deprecated('Use purchaseToken instead. Will be removed in 6.6.0') - final String? jwsRepresentation; + /// Retrieve all pending transactions in the StoreKit queue + Future> getPendingTransactionsIOS(); - /// Latest transaction information (StoreKit 2) - final Map? latestTransaction; + /// Get the currently promoted product (iOS 11+) + Future getPromotedProductIOS(); - /// Raw validation response from platform - final Map? rawResponse; + /// Get base64-encoded receipt data for validation + Future getReceiptDataIOS(); - /// Platform that performed the validation - final IapPlatform? platform; + /// Get the current App Store storefront country code + Future getStorefrontIOS(); - ReceiptValidationResult({ - required this.isValid, - this.errorMessage, - this.receiptData, - this.purchaseToken, - this.jwsRepresentation, - this.latestTransaction, - this.rawResponse, - this.platform, + /// Get the transaction JWS (StoreKit 2) + Future getTransactionJwsIOS({ + required String transactionId, }); - factory ReceiptValidationResult.fromJson(Map json) { - return ReceiptValidationResult( - isValid: json['isValid'] as bool? ?? false, - errorMessage: json['errorMessage'] as String?, - receiptData: json['receiptData'] as String?, - purchaseToken: json['purchaseToken'] as String?, - jwsRepresentation: json['jwsRepresentation'] - as String?, // Kept for backward compatibility - latestTransaction: json['latestTransaction'] != null - ? Map.from(json['latestTransaction'] as Map) - : null, - rawResponse: json['rawResponse'] != null - ? Map.from(json['rawResponse'] as Map) - : null, - platform: json['platform'] != null - ? (json['platform'] == 'ios' ? IapPlatform.ios : IapPlatform.android) - : null, - ); - } + /// Check whether the user has active subscriptions + Future hasActiveSubscriptions({ + List? subscriptionIds, + }); - Map toJson() { - return { - 'isValid': isValid, - if (errorMessage != null) 'errorMessage': errorMessage, - if (receiptData != null) 'receiptData': receiptData, - if (purchaseToken != null) 'purchaseToken': purchaseToken, - // ignore: deprecated_member_use_from_same_package - if (jwsRepresentation != null) - // ignore: deprecated_member_use_from_same_package - 'jwsRepresentation': jwsRepresentation, // Backward compatibility - if (latestTransaction != null) 'latestTransaction': latestTransaction, - if (rawResponse != null) 'rawResponse': rawResponse, - if (platform != null) - 'platform': platform == IapPlatform.ios ? 'ios' : 'android', - }; - } + /// Check introductory offer eligibility for specific products + Future isEligibleForIntroOfferIOS({ + required List productIds, + }); - @override - String toString() { - final platformStr = platform == null - ? 'unknown' - : (platform == IapPlatform.ios ? 'ios' : 'android'); - return 'ReceiptValidationResult{isValid: $isValid, errorMessage: $errorMessage, platform: $platformStr}'; - } + /// Verify a StoreKit 2 transaction signature + Future isTransactionVerifiedIOS({ + required String transactionId, + }); + + /// Get the latest transaction for a product using StoreKit 2 + Future latestTransactionIOS({ + required String sku, + }); + + /// Get StoreKit 2 subscription status details (iOS 15+) + Future> subscriptionStatusIOS({ + List? skus, + }); } -/// Deep link options (OpenIAP compliant) -class DeepLinkOptions { - final String? scheme; - final String? host; - final String? path; +/// GraphQL root subscription operations. +abstract class SubscriptionResolver { + /// Fires when the App Store surfaces a promoted product (iOS only) + Future promotedProductIOS(); - DeepLinkOptions({this.scheme, this.host, this.path}); + /// Fires when a purchase fails or is cancelled + Future purchaseError(); - Map toMap() { - return { - if (scheme != null) 'scheme': scheme, - if (host != null) 'host': host, - if (path != null) 'path': path, - }; - } + /// Fires when a purchase completes successfully or a pending purchase resolves + Future purchaseUpdated(); } -// ignore_for_file: constant_identifier_names diff --git a/lib/types/iap_android_types.dart b/lib/types/iap_android_types.dart deleted file mode 100644 index ba76290e8..000000000 --- a/lib/types/iap_android_types.dart +++ /dev/null @@ -1,354 +0,0 @@ -/// Android-specific types for in-app purchases -library; - -/// Android product information -class ProductAndroid { - final String productId; - final String price; - final String currency; - final String localizedPrice; - final String title; - final String description; - final String subscriptionPeriodAndroid; - final String freeTrialPeriodAndroid; - final String introductoryPriceAndroid; - final String introductoryPricePeriodAndroid; - final String introductoryPriceCyclesAndroid; - final String iconUrl; - final String originalJson; - final List subscriptionOffersAndroid; - - ProductAndroid({ - required this.productId, - required this.price, - required this.currency, - required this.localizedPrice, - required this.title, - required this.description, - required this.subscriptionPeriodAndroid, - required this.freeTrialPeriodAndroid, - required this.introductoryPriceAndroid, - required this.introductoryPricePeriodAndroid, - required this.introductoryPriceCyclesAndroid, - required this.iconUrl, - required this.originalJson, - required this.subscriptionOffersAndroid, - }); - - factory ProductAndroid.fromJson(Map json) { - return ProductAndroid( - productId: (json['productId'] as String?) ?? '', - price: json['price']?.toString() ?? '', - currency: (json['currency'] as String?) ?? '', - localizedPrice: (json['localizedPrice'] as String?) ?? '', - title: (json['title'] as String?) ?? '', - description: (json['description'] as String?) ?? '', - subscriptionPeriodAndroid: - (json['subscriptionPeriodAndroid'] as String?) ?? '', - freeTrialPeriodAndroid: (json['freeTrialPeriodAndroid'] as String?) ?? '', - introductoryPriceAndroid: - (json['introductoryPriceAndroid'] as String?) ?? '', - introductoryPricePeriodAndroid: - (json['introductoryPricePeriodAndroid'] as String?) ?? '', - introductoryPriceCyclesAndroid: - (json['introductoryPriceCyclesAndroid'] as String?) ?? '', - iconUrl: (json['iconUrl'] as String?) ?? '', - originalJson: (json['originalJson'] as String?) ?? '', - subscriptionOffersAndroid: (json['subscriptionOffersAndroid'] - as List?) - ?.map( - (e) => - SubscriptionOfferDetail.fromJson(e as Map), - ) - .toList() ?? - [], - ); - } - - Map toJson() { - return { - 'productId': productId, - 'price': price, - 'currency': currency, - 'localizedPrice': localizedPrice, - 'title': title, - 'description': description, - 'subscriptionPeriodAndroid': subscriptionPeriodAndroid, - 'freeTrialPeriodAndroid': freeTrialPeriodAndroid, - 'introductoryPriceAndroid': introductoryPriceAndroid, - 'introductoryPricePeriodAndroid': introductoryPricePeriodAndroid, - 'introductoryPriceCyclesAndroid': introductoryPriceCyclesAndroid, - 'iconUrl': iconUrl, - 'originalJson': originalJson, - 'subscriptionOffersAndroid': - subscriptionOffersAndroid.map((e) => e.toJson()).toList(), - }; - } -} - -/// Android subscription offer details -class SubscriptionOfferDetail { - final String offerToken; - final List pricingPhases; - - SubscriptionOfferDetail({ - required this.offerToken, - required this.pricingPhases, - }); - - factory SubscriptionOfferDetail.fromJson(Map json) { - return SubscriptionOfferDetail( - offerToken: (json['offerToken'] as String?) ?? '', - pricingPhases: (json['pricingPhases'] as List?) - ?.map((e) => PricingPhase.fromJson(e as Map)) - .toList() ?? - [], - ); - } - - Map toJson() { - return { - 'offerToken': offerToken, - 'pricingPhases': pricingPhases.map((e) => e.toJson()).toList(), - }; - } -} - -/// Android pricing phase for subscriptions -class PricingPhase { - final double price; - final String currency; - final String billingPeriod; - final String formattedPrice; - final int billingCycleCount; - final String recurrenceMode; - - PricingPhase({ - required this.price, - required this.currency, - required this.billingPeriod, - required this.formattedPrice, - required this.billingCycleCount, - required this.recurrenceMode, - }); - - factory PricingPhase.fromJson(Map json) { - return PricingPhase( - price: (json['price'] as num?)?.toDouble() ?? 0.0, - currency: (json['currency'] as String?) ?? '', - billingPeriod: (json['billingPeriod'] as String?) ?? '', - formattedPrice: (json['formattedPrice'] as String?) ?? '', - billingCycleCount: (json['billingCycleCount'] as int?) ?? 0, - recurrenceMode: (json['recurrenceMode'] as String?) ?? '', - ); - } - - Map toJson() { - return { - 'price': price, - 'currency': currency, - 'billingPeriod': billingPeriod, - 'formattedPrice': formattedPrice, - 'billingCycleCount': billingCycleCount, - 'recurrenceMode': recurrenceMode, - }; - } -} - -/// Android purchase request properties -class RequestPurchaseAndroidProps { - final String skus; - final bool? isOfferPersonalized; - final String? obfuscatedAccountId; - final String? obfuscatedProfileId; - final String? purchaseToken; - - RequestPurchaseAndroidProps({ - required this.skus, - this.isOfferPersonalized, - this.obfuscatedAccountId, - this.obfuscatedProfileId, - this.purchaseToken, - }); - - Map toJson() { - return { - 'skus': skus, - if (isOfferPersonalized != null) - 'isOfferPersonalized': isOfferPersonalized, - if (obfuscatedAccountId != null) - 'obfuscatedAccountId': obfuscatedAccountId, - if (obfuscatedProfileId != null) - 'obfuscatedProfileId': obfuscatedProfileId, - if (purchaseToken != null) 'purchaseToken': purchaseToken, - }; - } -} - -/// Android subscription purchase request properties -class RequestPurchaseSubscriptionAndroid { - final String purchaseToken; - final ProrationModeAndroid prorationMode; - - RequestPurchaseSubscriptionAndroid({ - required this.purchaseToken, - required this.prorationMode, - }); - - Map toJson() { - return { - 'purchaseToken': purchaseToken, - 'prorationMode': prorationMode.index, - }; - } -} - -/// Android proration modes -enum ProrationModeAndroid { - /// Replacement takes effect immediately, and the user is charged the full price - /// of the new plan and is given a full billing cycle of subscription, - /// plus remaining prorated time from the old plan. - immediateAndChargeFullPrice, - - /// Replacement takes effect immediately, and the billing cycle remains the same. - /// The price for the remaining period will be charged. - /// This is the default behavior. - immediateWithTimeProration, - - /// Replacement takes effect immediately, and the new plan will take effect - /// immediately and be charged when the old plan expires. - immediateWithoutProration, - - /// Replacement takes effect immediately, and the user is charged the prorated - /// price for the rest of the billing period. - immediateAndChargeProratedPrice, - - /// Replacement takes effect when the old plan expires. - deferred, -} - -/// Android purchase information -class PurchaseAndroid { - final String? productId; - final String? transactionId; - final String? transactionReceipt; - final String? purchaseToken; - final int? transactionDate; - final String? dataAndroid; - final String? signatureAndroid; - final String? orderId; - final int? purchaseStateAndroid; - final bool? isAcknowledgedAndroid; - final String? packageNameAndroid; - final String? developerPayloadAndroid; - final String? accountIdentifiersAndroid; - final String? obfuscatedAccountIdAndroid; - final String? obfuscatedProfileIdAndroid; - - PurchaseAndroid({ - this.productId, - this.transactionId, - this.transactionReceipt, - this.purchaseToken, - this.transactionDate, - this.dataAndroid, - this.signatureAndroid, - this.orderId, - this.purchaseStateAndroid, - this.isAcknowledgedAndroid, - this.packageNameAndroid, - this.developerPayloadAndroid, - this.accountIdentifiersAndroid, - this.obfuscatedAccountIdAndroid, - this.obfuscatedProfileIdAndroid, - }); - - factory PurchaseAndroid.fromJson(Map json) { - return PurchaseAndroid( - productId: json['productId'] as String?, - transactionId: json['transactionId'] as String?, - transactionReceipt: json['transactionReceipt'] as String?, - purchaseToken: json['purchaseToken'] as String?, - transactionDate: json['transactionDate'] as int?, - dataAndroid: json['dataAndroid'] as String?, - signatureAndroid: json['signatureAndroid'] as String?, - orderId: json['orderId'] as String?, - purchaseStateAndroid: json['purchaseStateAndroid'] as int?, - isAcknowledgedAndroid: json['isAcknowledgedAndroid'] as bool?, - packageNameAndroid: json['packageNameAndroid'] as String?, - developerPayloadAndroid: json['developerPayloadAndroid'] as String?, - accountIdentifiersAndroid: json['accountIdentifiersAndroid'] as String?, - obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?, - obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?, - ); - } - - Map toJson() { - return { - if (productId != null) 'productId': productId, - if (transactionId != null) 'transactionId': transactionId, - if (transactionReceipt != null) 'transactionReceipt': transactionReceipt, - if (purchaseToken != null) 'purchaseToken': purchaseToken, - if (transactionDate != null) 'transactionDate': transactionDate, - if (dataAndroid != null) 'dataAndroid': dataAndroid, - if (signatureAndroid != null) 'signatureAndroid': signatureAndroid, - if (orderId != null) 'orderId': orderId, - if (purchaseStateAndroid != null) - 'purchaseStateAndroid': purchaseStateAndroid, - if (isAcknowledgedAndroid != null) - 'isAcknowledgedAndroid': isAcknowledgedAndroid, - if (packageNameAndroid != null) 'packageNameAndroid': packageNameAndroid, - if (developerPayloadAndroid != null) - 'developerPayloadAndroid': developerPayloadAndroid, - if (accountIdentifiersAndroid != null) - 'accountIdentifiersAndroid': accountIdentifiersAndroid, - if (obfuscatedAccountIdAndroid != null) - 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid, - if (obfuscatedProfileIdAndroid != null) - 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid, - }; - } -} - -/// Android billing response codes -class BillingResponseCodeAndroid { - static const int ok = 0; - static const int userCanceled = 1; - static const int serviceUnavailable = 2; - static const int billingUnavailable = 3; - static const int itemUnavailable = 4; - static const int developerError = 5; - static const int error = 6; - static const int itemAlreadyOwned = 7; - static const int itemNotOwned = 8; - static const int serviceDisconnected = -1; - static const int featureNotSupported = -2; - static const int networkError = 12; -} - -/// Android purchase states -enum PurchaseStateAndroid { pending, purchased, unspecified } - -/// Android account identifiers -class AccountIdentifiers { - final String? obfuscatedAccountId; - final String? obfuscatedProfileId; - - AccountIdentifiers({this.obfuscatedAccountId, this.obfuscatedProfileId}); - - factory AccountIdentifiers.fromJson(Map json) { - return AccountIdentifiers( - obfuscatedAccountId: json['obfuscatedAccountId'] as String?, - obfuscatedProfileId: json['obfuscatedProfileId'] as String?, - ); - } - - Map toJson() { - return { - if (obfuscatedAccountId != null) - 'obfuscatedAccountId': obfuscatedAccountId, - if (obfuscatedProfileId != null) - 'obfuscatedProfileId': obfuscatedProfileId, - }; - } -} diff --git a/lib/types/iap_ios_types.dart b/lib/types/iap_ios_types.dart deleted file mode 100644 index 1a7cef357..000000000 --- a/lib/types/iap_ios_types.dart +++ /dev/null @@ -1,415 +0,0 @@ -/// iOS-specific types for in-app purchases -library; - -/// iOS product information -class ProductIos { - final String productId; - final String price; - final String currency; - final String localizedPrice; - final String title; - final String description; - final PeriodUnit? periodUnit; - final PeriodUnitIOS? periodUnitIOS; - final String? discountId; - final List discounts; - final List introductoryOffers; - final List subscriptionOffers; - - ProductIos({ - required this.productId, - required this.price, - required this.currency, - required this.localizedPrice, - required this.title, - required this.description, - required this.discounts, - required this.introductoryOffers, - required this.subscriptionOffers, - this.periodUnit, - this.periodUnitIOS, - this.discountId, - }); - - factory ProductIos.fromJson(Map json) { - return ProductIos( - productId: (json['productId'] as String?) ?? '', - price: json['price']?.toString() ?? '', - currency: (json['currency'] as String?) ?? '', - localizedPrice: (json['localizedPrice'] as String?) ?? '', - title: (json['title'] as String?) ?? '', - description: (json['description'] as String?) ?? '', - periodUnit: json['periodUnit'] != null - ? PeriodUnit.values.firstWhere( - (e) => e.toString().split('.').last == json['periodUnit'], - orElse: () => PeriodUnit.unknown, - ) - : null, - periodUnitIOS: json['periodUnitIOS'] != null - ? PeriodUnitIOS.values.firstWhere( - (e) => e.toString().split('.').last == json['periodUnitIOS'], - orElse: () => PeriodUnitIOS.none, - ) - : null, - discountId: json['discountId'] as String?, - discounts: (json['discounts'] as List?) - ?.map((e) => PaymentDiscount.fromJson(e as Map)) - .toList() ?? - [], - introductoryOffers: (json['introductoryOffers'] as List?) - ?.map((e) => PaymentDiscount.fromJson(e as Map)) - .toList() ?? - [], - subscriptionOffers: (json['subscriptionOffers'] as List?) - ?.map((e) => SubscriptionInfo.fromJson(e as Map)) - .toList() ?? - [], - ); - } - - Map toJson() { - return { - 'productId': productId, - 'price': price, - 'currency': currency, - 'localizedPrice': localizedPrice, - 'title': title, - 'description': description, - if (periodUnit != null) - 'periodUnit': periodUnit!.toString().split('.').last, - if (periodUnitIOS != null) - 'periodUnitIOS': periodUnitIOS!.toString().split('.').last, - if (discountId != null) 'discountId': discountId, - 'discounts': discounts.map((e) => e.toJson()).toList(), - 'introductoryOffers': introductoryOffers.map((e) => e.toJson()).toList(), - 'subscriptionOffers': subscriptionOffers.map((e) => e.toJson()).toList(), - }; - } -} - -/// Period units for subscriptions -enum PeriodUnit { day, week, month, year, unknown } - -/// iOS-specific period units -enum PeriodUnitIOS { day, week, month, year, none } - -/// iOS subscription information -class SubscriptionInfo { - final List subscriptionOffers; - final String? groupIdentifier; - final String? subscriptionPeriod; - - SubscriptionInfo({ - required this.subscriptionOffers, - this.groupIdentifier, - this.subscriptionPeriod, - }); - - factory SubscriptionInfo.fromJson(Map json) { - return SubscriptionInfo( - subscriptionOffers: (json['subscriptionOffers'] as List?) - ?.map( - (e) => SubscriptionOffer.fromJson(e as Map), - ) - .toList() ?? - [], - groupIdentifier: json['groupIdentifier'] as String?, - subscriptionPeriod: json['subscriptionPeriod'] as String?, - ); - } - - Map toJson() { - return { - 'subscriptionOffers': subscriptionOffers.map((e) => e.toJson()).toList(), - if (groupIdentifier != null) 'groupIdentifier': groupIdentifier, - if (subscriptionPeriod != null) 'subscriptionPeriod': subscriptionPeriod, - }; - } -} - -/// iOS subscription offer -class SubscriptionOffer { - final String? sku; - final PaymentDiscount? offer; - - SubscriptionOffer({this.sku, this.offer}); - - factory SubscriptionOffer.fromJson(Map json) { - return SubscriptionOffer( - sku: json['sku'] as String?, - offer: json['offer'] != null - ? PaymentDiscount.fromJson(json['offer'] as Map) - : null, - ); - } - - Map toJson() { - return { - if (sku != null) 'sku': sku, - if (offer != null) 'offer': offer!.toJson(), - }; - } -} - -/// iOS payment discount information -class PaymentDiscount { - final String identifier; - final String type; - final String price; - final String localizedPrice; - final String paymentMode; - final int numberOfPeriods; - final String? subscriptionPeriod; - - PaymentDiscount({ - required this.identifier, - required this.type, - required this.price, - required this.localizedPrice, - required this.paymentMode, - required this.numberOfPeriods, - this.subscriptionPeriod, - }); - - factory PaymentDiscount.fromJson(Map json) { - return PaymentDiscount( - identifier: (json['identifier'] as String?) ?? '', - type: (json['type'] as String?) ?? '', - price: json['price']?.toString() ?? '', - localizedPrice: (json['localizedPrice'] as String?) ?? '', - paymentMode: (json['paymentMode'] as String?) ?? '', - numberOfPeriods: (json['numberOfPeriods'] as int?) ?? 0, - subscriptionPeriod: json['subscriptionPeriod'] as String?, - ); - } - - Map toJson() { - return { - 'identifier': identifier, - 'type': type, - 'price': price, - 'localizedPrice': localizedPrice, - 'paymentMode': paymentMode, - 'numberOfPeriods': numberOfPeriods, - if (subscriptionPeriod != null) 'subscriptionPeriod': subscriptionPeriod, - }; - } -} - -/// iOS purchase request properties -class RequestPurchaseIosProps { - final String sku; - final String? applicationUsername; - final bool? simulatesAskToBuyInSandbox; - final int? quantity; - final PaymentDiscount? withOffer; - - RequestPurchaseIosProps({ - required this.sku, - this.applicationUsername, - this.simulatesAskToBuyInSandbox, - this.quantity, - this.withOffer, - }); - - Map toJson() { - return { - 'sku': sku, - if (applicationUsername != null) - 'applicationUsername': applicationUsername, - if (simulatesAskToBuyInSandbox != null) - 'simulatesAskToBuyInSandbox': simulatesAskToBuyInSandbox, - if (quantity != null) 'quantity': quantity, - if (withOffer != null) 'withOffer': withOffer!.toJson(), - }; - } -} - -/// iOS purchase information -class PurchaseIos { - final String? productId; - final String? transactionId; - final String? transactionReceipt; - final String? applicationUsername; - final int? transactionDate; - final String? originalTransactionDateIOS; - final String? originalTransactionIdentifierIOS; - final bool? isUpgrade; - final String? verificationData; - - PurchaseIos({ - this.productId, - this.transactionId, - this.transactionReceipt, - this.applicationUsername, - this.transactionDate, - this.originalTransactionDateIOS, - this.originalTransactionIdentifierIOS, - this.isUpgrade, - this.verificationData, - }); - - factory PurchaseIos.fromJson(Map json) { - return PurchaseIos( - productId: json['productId'] as String?, - transactionId: json['transactionId'] as String?, - transactionReceipt: json['transactionReceipt'] as String?, - applicationUsername: json['applicationUsername'] as String?, - transactionDate: json['transactionDate'] as int?, - originalTransactionDateIOS: json['originalTransactionDateIOS'] as String?, - originalTransactionIdentifierIOS: - json['originalTransactionIdentifierIOS'] as String?, - isUpgrade: json['isUpgrade'] as bool?, - verificationData: json['verificationData'] as String?, - ); - } - - Map toJson() { - return { - if (productId != null) 'productId': productId, - if (transactionId != null) 'transactionId': transactionId, - if (transactionReceipt != null) 'transactionReceipt': transactionReceipt, - if (applicationUsername != null) - 'applicationUsername': applicationUsername, - if (transactionDate != null) 'transactionDate': transactionDate, - if (originalTransactionDateIOS != null) - 'originalTransactionDateIOS': originalTransactionDateIOS, - if (originalTransactionIdentifierIOS != null) - 'originalTransactionIdentifierIOS': originalTransactionIdentifierIOS, - if (isUpgrade != null) 'isUpgrade': isUpgrade, - if (verificationData != null) 'verificationData': verificationData, - }; - } -} - -/// iOS App Transaction information (iOS 18.4+) -class AppTransactionIOS { - final String? appAppleId; - final String? bundleId; - final String? originalAppVersion; - final String? originalPurchaseDate; - final String? appTransactionID; - final String? originalPlatform; - final String? deviceVerification; - final String? deviceVerificationNonce; - final String? preorderDate; - - AppTransactionIOS({ - this.appAppleId, - this.bundleId, - this.originalAppVersion, - this.originalPurchaseDate, - this.appTransactionID, - this.originalPlatform, - this.deviceVerification, - this.deviceVerificationNonce, - this.preorderDate, - }); - - factory AppTransactionIOS.fromJson(Map json) { - return AppTransactionIOS( - appAppleId: json['appAppleId'] as String?, - bundleId: json['bundleId'] as String?, - originalAppVersion: json['originalAppVersion'] as String?, - originalPurchaseDate: json['originalPurchaseDate'] as String?, - appTransactionID: json['appTransactionID'] as String?, - originalPlatform: json['originalPlatform'] as String?, - deviceVerification: json['deviceVerification'] as String?, - deviceVerificationNonce: json['deviceVerificationNonce'] as String?, - preorderDate: json['preorderDate'] as String?, - ); - } - - Map toJson() { - return { - if (appAppleId != null) 'appAppleId': appAppleId, - if (bundleId != null) 'bundleId': bundleId, - if (originalAppVersion != null) 'originalAppVersion': originalAppVersion, - if (originalPurchaseDate != null) - 'originalPurchaseDate': originalPurchaseDate, - if (appTransactionID != null) 'appTransactionID': appTransactionID, - if (originalPlatform != null) 'originalPlatform': originalPlatform, - if (deviceVerification != null) 'deviceVerification': deviceVerification, - if (deviceVerificationNonce != null) - 'deviceVerificationNonce': deviceVerificationNonce, - if (preorderDate != null) 'preorderDate': preorderDate, - }; - } -} - -/// iOS transaction states -enum TransactionStateIOS { purchasing, purchased, failed, restored, deferred } - -/// iOS promotional offer -class PromotionalOffer { - final String offerIdentifier; - final String keyIdentifier; - final String nonce; - final String signature; - final int timestamp; - - PromotionalOffer({ - required this.offerIdentifier, - required this.keyIdentifier, - required this.nonce, - required this.signature, - required this.timestamp, - }); - - factory PromotionalOffer.fromJson(Map json) { - return PromotionalOffer( - offerIdentifier: (json['offerIdentifier'] as String?) ?? '', - keyIdentifier: (json['keyIdentifier'] as String?) ?? '', - nonce: (json['nonce'] as String?) ?? '', - signature: (json['signature'] as String?) ?? '', - timestamp: (json['timestamp'] as int?) ?? 0, - ); - } - - Map toJson() { - return { - 'offerIdentifier': offerIdentifier, - 'keyIdentifier': keyIdentifier, - 'nonce': nonce, - 'signature': signature, - 'timestamp': timestamp, - }; - } -} - -/// iOS receipt validation response -class ReceiptValidationResponseIOS { - final int status; - final Map? receipt; - final Map? latestReceiptInfo; - final List>? latestReceipts; - - ReceiptValidationResponseIOS({ - required this.status, - this.receipt, - this.latestReceiptInfo, - this.latestReceipts, - }); - - factory ReceiptValidationResponseIOS.fromJson(Map json) { - return ReceiptValidationResponseIOS( - status: (json['status'] as int?) ?? 0, - receipt: json['receipt'] as Map?, - latestReceiptInfo: json['latest_receipt_info'] as Map?, - latestReceipts: json['latest_receipts'] != null - ? List>.from( - json['latest_receipts'] as List, - ) - : null, - ); - } -} - -/// iOS subscription status -enum SubscriptionStateIOS { - active, - expired, - inBillingRetry, - inGracePeriod, - revoked, -} diff --git a/lib/types_additions.dart b/lib/types_additions.dart deleted file mode 100644 index af74af538..000000000 --- a/lib/types_additions.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'types.dart'; - -// ============================================================================ -// ADDITIONAL TYPES -// ============================================================================ - -/// App Transaction information (iOS 16.0+) -class AppTransaction { - final String? appAppleId; - final String? bundleId; - final String? originalAppVersion; - final String? originalPurchaseDate; - final String? deviceVerification; - final String? deviceVerificationNonce; - - AppTransaction({ - this.appAppleId, - this.bundleId, - this.originalAppVersion, - this.originalPurchaseDate, - this.deviceVerification, - this.deviceVerificationNonce, - }); - - factory AppTransaction.fromJson(Map json) { - return AppTransaction( - appAppleId: json['appAppleId'] as String?, - bundleId: json['bundleId'] as String?, - originalAppVersion: json['originalAppVersion'] as String?, - originalPurchaseDate: json['originalPurchaseDate'] as String?, - deviceVerification: json['deviceVerification'] as String?, - deviceVerificationNonce: json['deviceVerificationNonce'] as String?, - ); - } - - Map toJson() { - return { - if (appAppleId != null) 'appAppleId': appAppleId, - if (bundleId != null) 'bundleId': bundleId, - if (originalAppVersion != null) 'originalAppVersion': originalAppVersion, - if (originalPurchaseDate != null) - 'originalPurchaseDate': originalPurchaseDate, - if (deviceVerification != null) 'deviceVerification': deviceVerification, - if (deviceVerificationNonce != null) - 'deviceVerificationNonce': deviceVerificationNonce, - }; - } -} - -/// Subscription purchase information -class SubscriptionPurchase extends Purchase { - final bool isActive; - final DateTime? expirationDate; - final String? subscriptionGroupId; - final bool? isInTrialPeriod; - final bool? isInIntroOfferPeriod; - final String? subscriptionPeriod; - - SubscriptionPurchase({ - required super.productId, - required this.isActive, // Platform - required super.platform, - super.transactionId, - super.transactionDate, - super.transactionReceipt, - super.purchaseToken, - this.expirationDate, - this.subscriptionGroupId, - this.isInTrialPeriod, - this.isInIntroOfferPeriod, - this.subscriptionPeriod, - // iOS specific - super.transactionStateIOS, - super.originalTransactionIdentifierIOS, - super.originalTransactionDateIOS, - super.quantityIOS, - super.environmentIOS, - super.expirationDateIOS, - // Android specific - super.isAcknowledgedAndroid, - super.purchaseStateAndroid, - super.signatureAndroid, - super.originalJson, - super.packageNameAndroid, - super.autoRenewingAndroid, - }); - - Map toJson() { - final json = { - 'productId': productId, - 'isActive': isActive, - if (transactionId != null) 'transactionId': transactionId, - if (transactionDate != null) 'transactionDate': transactionDate, - if (transactionReceipt != null) 'transactionReceipt': transactionReceipt, - if (purchaseToken != null) 'purchaseToken': purchaseToken, - if (expirationDate != null) - 'expirationDate': expirationDate!.toIso8601String(), - if (subscriptionGroupId != null) - 'subscriptionGroupId': subscriptionGroupId, - if (isInTrialPeriod != null) 'isInTrialPeriod': isInTrialPeriod, - if (isInIntroOfferPeriod != null) - 'isInIntroOfferPeriod': isInIntroOfferPeriod, - if (subscriptionPeriod != null) 'subscriptionPeriod': subscriptionPeriod, - 'platform': platform.name, - }; - return json; - } -} - -/// Active subscription information (OpenIAP compliant) -/// Used by getActiveSubscriptions() and hasActiveSubscriptions() -class ActiveSubscription { - /// Product identifier - final String productId; - - /// Always true for active subscriptions - final bool isActive; - - /// Subscription expiration date (iOS only) - final DateTime? expirationDateIOS; - - /// Auto-renewal status (Android only) - final bool? autoRenewingAndroid; - - /// Environment: 'Sandbox' | 'Production' (iOS only) - final String? environmentIOS; - - /// True if subscription expires within 7 days - final bool? willExpireSoon; - - /// Days remaining until expiration (iOS only) - final int? daysUntilExpirationIOS; - - ActiveSubscription({ - required this.productId, - required this.isActive, - this.expirationDateIOS, - this.autoRenewingAndroid, - this.environmentIOS, - this.willExpireSoon, - this.daysUntilExpirationIOS, - }); - - /// Creates ActiveSubscription from a Purchase or SubscriptionPurchase - factory ActiveSubscription.fromPurchase(Purchase purchase) { - DateTime? expirationDate; - bool? willExpireSoon; - int? daysUntilExpiration; - - // Get expiration date for iOS - if (purchase.platform == IapPlatform.ios) { - expirationDate = purchase.expirationDateIOS; - - // Calculate days until expiration and willExpireSoon - if (expirationDate != null) { - final now = DateTime.now(); - final difference = expirationDate.difference(now); - daysUntilExpiration = difference.inDays; - willExpireSoon = daysUntilExpiration <= 7; - } - } - - return ActiveSubscription( - productId: purchase.productId, - isActive: true, // Only active subscriptions are returned - expirationDateIOS: expirationDate, - autoRenewingAndroid: purchase.autoRenewingAndroid, - environmentIOS: purchase.environmentIOS, - willExpireSoon: willExpireSoon, - daysUntilExpirationIOS: daysUntilExpiration, - ); - } - - Map toJson() { - return { - 'productId': productId, - 'isActive': isActive, - if (expirationDateIOS != null) - 'expirationDateIOS': expirationDateIOS!.toIso8601String(), - if (autoRenewingAndroid != null) - 'autoRenewingAndroid': autoRenewingAndroid, - if (environmentIOS != null) 'environmentIOS': environmentIOS, - if (willExpireSoon != null) 'willExpireSoon': willExpireSoon, - if (daysUntilExpirationIOS != null) - 'daysUntilExpirationIOS': daysUntilExpirationIOS, - }; - } -} - -/// iOS-specific purchase class -class PurchaseIOS extends Purchase { - PurchaseIOS({ - required super.productId, - super.transactionId, - super.transactionDate, - super.transactionReceipt, - super.purchaseToken, - super.expirationDateIOS, - // Add other fields from Purchase parent class - super.transactionStateIOS, - super.originalTransactionIdentifierIOS, - super.originalTransactionDateIOS, - super.quantityIOS, - }) : super(platform: IapPlatform.ios); -} diff --git a/pubspec.yaml b/pubspec.yaml index d1cc74e9b..f801da0ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_inapp_purchase description: In App Purchase plugin for flutter. This project has been forked by react-native-iap and we are willing to share same experience with that on react-native. -version: 6.6.1 +version: 6.7.0 homepage: https://github.com/hyochan/flutter_inapp_purchase/blob/main/pubspec.yaml environment: sdk: ">=3.0.0 <4.0.0" diff --git a/scripts/generate-type.sh b/scripts/generate-type.sh new file mode 100755 index 000000000..0d677d6ed --- /dev/null +++ b/scripts/generate-type.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ZIP_URL="https://github.com/hyodotdev/openiap-gql/releases/download/1.0.2/openiap-dart.zip" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +TARGET_FILE="${REPO_ROOT}/lib/types.dart" +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +ZIP_PATH="${TMP_DIR}/openiap-dart.zip" + +if ! command -v curl >/dev/null 2>&1; then + echo "Error: curl is required but not installed." >&2 + exit 1 +fi + +if ! command -v unzip >/dev/null 2>&1; then + echo "Error: unzip is required but not installed." >&2 + exit 1 +fi + +echo "Downloading openiap-dart.zip from ${ZIP_URL}..." +curl -fL "${ZIP_URL}" -o "${ZIP_PATH}" + +echo "Extracting types.dart..." +unzip -q -d "${TMP_DIR}" "${ZIP_PATH}" types.dart + +if [ ! -f "${TMP_DIR}/types.dart" ]; then + echo "Error: types.dart not found in archive." >&2 + exit 1 +fi + +mkdir -p "$(dirname "${TARGET_FILE}")" + +echo "Replacing ${TARGET_FILE}" +cp "${TMP_DIR}/types.dart" "${TARGET_FILE}" + +echo "Done." diff --git a/test/available_purchases_test.dart b/test/available_purchases_test.dart deleted file mode 100644 index 74fa6a847..000000000 --- a/test/available_purchases_test.dart +++ /dev/null @@ -1,458 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:platform/platform.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('Available Purchases Tests', () { - late FlutterInappPurchase plugin; - final List methodChannelLog = []; - - setUp(() { - methodChannelLog.clear(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - methodChannelLog.add(methodCall); - switch (methodCall.method) { - case 'initConnection': - return true; - case 'endConnection': - return true; - case 'getAvailablePurchases': - return _getMockAvailablePurchases() - .map((item) => Map.from(item)) - .toList(); - case 'getPurchaseHistory': - return _getMockPurchaseHistory() - .map((item) => Map.from(item)) - .toList(); - case 'getPendingPurchases': - return _getMockPendingPurchases() - .map((item) => Map.from(item)) - .toList(); - case 'consumePurchaseAndroid': - return {'consumed': true}; - case 'acknowledgePurchaseAndroid': - return {'acknowledged': true}; - case 'finishTransaction': - return 'finished'; - case 'clearTransactionIOS': - return 'cleared'; - case 'getAvailableItems': - return _getMockAvailablePurchases() - .map((item) => Map.from(item)) - .toList(); - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), null); - }); - - group('Get Available Purchases', () { - test('getAvailablePurchases returns all purchases on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - - // The mock returns all items (including iOS ones) - expect(purchases.length, greaterThan(0)); - // Check that we have some purchases - final androidPurchases = - purchases.where((p) => p.purchaseToken != null).toList(); - expect(androidPurchases.isNotEmpty, true); - }); - - test('getAvailablePurchases returns all purchases on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - - // The mock returns all items - expect(purchases.length, greaterThan(0)); - // Check that we have some purchases - expect(purchases[0].productId, isNotEmpty); - }); - - test('getAvailablePurchases includes unified purchaseToken', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - - for (final purchase in purchases) { - // purchaseToken should be available for Android-style purchases - // (ones that have a purchaseToken in the mock data) - if (purchase.purchaseToken != null) { - expect(purchase.purchaseToken, isNotEmpty); - } - } - }); - - test('getAvailablePurchases handles empty list', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'getAvailablePurchases') { - return >[]; - } - if (methodCall.method == 'getAvailableItems') { - return >[]; - } - - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - expect(purchases.isEmpty, true); - }); - }); - - group('Purchase History', () { - test('getPurchaseHistory returns history on Android', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'getPurchaseHistory') { - return _getMockPurchaseHistory() - .map((item) => Map.from(item)) - .toList(); - } - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - // getPurchaseHistory is Android-only - final history = await plugin.channel.invokeMethod('getPurchaseHistory'); - expect((history as List).length, 5); - }); - - test('getPurchaseHistory on iOS uses getAvailablePurchases', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - // On iOS, history is retrieved through getAvailablePurchases - final purchases = await plugin.getAvailablePurchases(); - // The mock returns all available items - expect(purchases.length, greaterThan(0)); - // For iOS, it uses getAvailableItems internally - expect(methodChannelLog.last.method, 'getAvailableItems'); - }); - }); - - group('Pending Purchases', () { - test('handles pending purchases on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final pendingPurchases = await plugin.channel.invokeMethod( - 'getPendingPurchases', - ); - expect((pendingPurchases as List).length, 2); - - final pending = pendingPurchases[0] as Map; - expect(pending['productId'], 'pending_product_1'); - expect(pending['purchaseState'], 4); // Pending state - }); - }); - - group('Transaction Management', () { - test('finishTransaction for consumable product', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'consumable.coins', - transactionId: 'GPA.CONS-001', - purchaseToken: 'consume_me', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await plugin.finishTransaction(purchase, isConsumable: true); - - expect(methodChannelLog.last.method, 'consumePurchaseAndroid'); - expect(methodChannelLog.last.arguments['purchaseToken'], 'consume_me'); - }); - - test('finishTransaction for non-consumable product', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'premium.upgrade', - transactionId: 'GPA.PREM-001', - purchaseToken: 'acknowledge_me', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'acknowledgePurchaseAndroid'); - expect( - methodChannelLog.last.arguments['purchaseToken'], - 'acknowledge_me', - ); - }); - - test('finishTransaction skips already acknowledged purchase', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'already.done', - transactionId: 'GPA.DONE-001', - purchaseToken: 'already_acknowledged', - platform: IapPlatform.android, - isAcknowledgedAndroid: true, - ); - - await plugin.finishTransaction(purchase); - - // Should not call any acknowledgement method - expect(methodChannelLog.last.method, 'initConnection'); - }); - - test('finishTransaction on iOS uses transaction ID', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'ios.product', - transactionId: '2000000123', - platform: IapPlatform.ios, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'finishTransaction'); - expect(methodChannelLog.last.arguments['transactionId'], '2000000123'); - }); - - test('clearTransactionIOS clears iOS transactions', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - await plugin.channel.invokeMethod('clearTransactionIOS'); - - expect(methodChannelLog.last.method, 'clearTransactionIOS'); - }); - }); - - group('Purchase Restoration', () { - test('restores purchases on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final restored = await plugin.getAvailablePurchases(); - - expect(restored.length, greaterThan(0)); - // On Android, it uses getAvailableItems - expect(methodChannelLog.last.method, 'getAvailableItems'); - }); - - test('restores purchases on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final restored = await plugin.getAvailablePurchases(); - - expect(restored.length, greaterThan(0)); - // On iOS, it uses getAvailableItems - expect(methodChannelLog.last.method, 'getAvailableItems'); - }); - }); - - group('Purchase Validation', () { - test('validates purchase fields', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - - for (final purchase in purchases) { - expect(purchase.productId, isNotEmpty); - // Either transactionId or id should be present - final idToCheck = purchase.transactionId ?? purchase.id; - expect(idToCheck, isNotEmpty); - - // PurchaseToken is only required for Android purchases - // Skip this check as platform might not be set correctly - } - }); - - test('handles purchases with missing fields', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'getAvailablePurchases') { - return >[ - { - 'productId': 'incomplete_purchase', - // Missing other fields - }, - ]; - } - if (methodCall.method == 'getAvailableItems') { - return >[ - { - 'productId': 'incomplete_purchase', - // Missing other fields - }, - ]; - } - - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - // The mock returns empty list now - expect(purchases.length, 0); - }); - }); - }); -} - -List> _getMockAvailablePurchases() { - return >[ - { - 'productId': 'product_1', - 'purchaseToken': 'token_1', - 'transactionId': 'GPA.001', - 'purchaseState': 1, - 'isAcknowledged': true, - }, - { - 'productId': 'product_2', - 'purchaseToken': 'token_2', - 'transactionId': 'GPA.002', - 'purchaseState': 1, - 'isAcknowledged': true, - }, - { - 'productId': 'subscription_1', - 'purchaseToken': 'token_sub_1', - 'transactionId': 'GPA.SUB.001', - 'purchaseState': 1, - 'isAcknowledged': true, - 'autoRenewing': true, - }, - { - 'productId': 'ios_product_1', - 'transactionId': '1000000001', - }, - { - 'productId': 'ios_subscription_1', - 'transactionId': '1000000002', - }, - ]; -} - -List> _getMockPurchaseHistory() { - return >[ - { - 'productId': 'history_1', - 'purchaseToken': 'hist_token_1', - 'transactionId': 'GPA.HIST.001', - 'transactionDate': '2024-01-01T10:00:00Z', - }, - { - 'productId': 'history_2', - 'purchaseToken': 'hist_token_2', - 'transactionId': 'GPA.HIST.002', - 'transactionDate': '2024-01-15T10:00:00Z', - }, - { - 'productId': 'history_3', - 'purchaseToken': 'hist_token_3', - 'transactionId': 'GPA.HIST.003', - 'transactionDate': '2024-02-01T10:00:00Z', - }, - { - 'productId': 'history_4', - 'purchaseToken': 'hist_token_4', - 'transactionId': 'GPA.HIST.004', - 'transactionDate': '2024-02-15T10:00:00Z', - }, - { - 'productId': 'history_5', - 'purchaseToken': 'hist_token_5', - 'transactionId': 'GPA.HIST.005', - 'transactionDate': '2024-03-01T10:00:00Z', - }, - ]; -} - -List> _getMockPendingPurchases() { - return >[ - { - 'productId': 'pending_product_1', - 'purchaseToken': 'pending_token_1', - 'transactionId': 'GPA.PEND.001', - 'purchaseState': 4, // Pending - }, - { - 'productId': 'pending_product_2', - 'purchaseToken': 'pending_token_2', - 'transactionId': 'GPA.PEND.002', - 'purchaseState': 4, // Pending - }, - ]; -} diff --git a/test/enums_test.dart b/test/enums_test.dart deleted file mode 100644 index ed10dead1..000000000 --- a/test/enums_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_inapp_purchase/types.dart'; - -void main() { - group('Enums', () { - test('ErrorCode should contain all expected values', () { - expect(ErrorCode.values.length, greaterThan(5)); - expect(ErrorCode.Unknown, isA()); - expect(ErrorCode.UserCancelled, isA()); - expect(ErrorCode.UserError, isA()); - expect(ErrorCode.ItemUnavailable, isA()); - expect(ErrorCode.ProductNotAvailable, isA()); - expect(ErrorCode.ProductAlreadyOwned, isA()); - expect(ErrorCode.NetworkError, isA()); - }); - - test('IapPlatform should work correctly', () { - expect(IapPlatform.android.toString(), contains('android')); - expect(IapPlatform.ios.toString(), contains('ios')); - }); - - test('PurchaseState should work correctly', () { - expect(PurchaseState.purchased.toString(), contains('purchased')); - expect(PurchaseState.pending.toString(), contains('pending')); - expect(PurchaseState.unspecified.toString(), contains('unspecified')); - }); - - test('TransactionState should work correctly', () { - expect(TransactionState.purchasing.toString(), contains('purchasing')); - expect(TransactionState.purchased.toString(), contains('purchased')); - expect(TransactionState.failed.toString(), contains('failed')); - expect(TransactionState.restored.toString(), contains('restored')); - expect(TransactionState.deferred.toString(), contains('deferred')); - }); - - test('RecurrenceMode should work correctly', () { - expect(RecurrenceMode.infiniteRecurring, isA()); - expect(RecurrenceMode.finiteRecurring, isA()); - expect(RecurrenceMode.nonRecurring, isA()); - }); - - test('ProductType constants should work correctly', () { - expect(ProductType.inapp, 'inapp'); - expect(ProductType.subs, 'subs'); - }); - }); -} diff --git a/test/errors_test.dart b/test/errors_test.dart deleted file mode 100644 index 9f7f50f7c..000000000 --- a/test/errors_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_inapp_purchase/types.dart'; - -void main() { - group('Errors', () { - group('PurchaseError', () { - test('should create with all fields', () { - final error = PurchaseError( - name: 'CustomError', - message: 'User cancelled', - code: ErrorCode.UserCancelled, - platform: IapPlatform.android, - responseCode: 6, - debugMessage: 'Debug info', - productId: 'product_123', - ); - - expect(error.name, 'CustomError'); - expect(error.code, ErrorCode.UserCancelled); - expect(error.message, 'User cancelled'); - expect(error.platform, IapPlatform.android); - expect(error.responseCode, 6); - expect(error.debugMessage, 'Debug info'); - expect(error.productId, 'product_123'); - }); - - test('should create with minimal fields', () { - final error = PurchaseError( - message: 'Network failed', - code: ErrorCode.NetworkError, - platform: IapPlatform.ios, - ); - - expect(error.name, '[flutter_inapp_purchase]: PurchaseError'); - expect(error.code, ErrorCode.NetworkError); - expect(error.message, 'Network failed'); - expect(error.platform, IapPlatform.ios); - expect(error.responseCode, isNull); - expect(error.debugMessage, isNull); - expect(error.productId, isNull); - }); - - test('toString should return formatted string', () { - final error = PurchaseError( - message: 'Item not found', - code: ErrorCode.ItemUnavailable, - platform: IapPlatform.android, - responseCode: 4, - ); - - final result = error.toString(); - expect(result, contains('Item not found')); - expect(result, isA()); - }); - - test('should create from platform error', () { - final errorData = { - 'code': 'E_USER_CANCELLED', - 'message': 'User cancelled the purchase', - 'responseCode': 6, - }; - - final error = PurchaseError.fromPlatformError( - errorData, - IapPlatform.android, - ); - - expect(error.code, ErrorCode.UserCancelled); - expect(error.message, 'User cancelled the purchase'); - expect(error.platform, IapPlatform.android); - }); - }); - - group('PurchaseResult', () { - test('should create with all parameters', () { - final result = PurchaseResult( - responseCode: 0, - debugMessage: 'Success', - code: 'OK', - message: 'Purchase completed', - purchaseTokenAndroid: 'token_123', - ); - - expect(result.responseCode, 0); - expect(result.debugMessage, 'Success'); - expect(result.code, 'OK'); - expect(result.message, 'Purchase completed'); - expect(result.purchaseTokenAndroid, 'token_123'); - }); - - test('should create from JSON', () { - final json = { - 'responseCode': 5, - 'debugMessage': 'Error occurred', - 'code': 'ERR_001', - 'message': 'Purchase failed', - 'purchaseTokenAndroid': 'failed_token', - }; - - final result = PurchaseResult.fromJSON(json); - expect(result.responseCode, 5); - expect(result.debugMessage, 'Error occurred'); - expect(result.code, 'ERR_001'); - expect(result.message, 'Purchase failed'); - expect(result.purchaseTokenAndroid, 'failed_token'); - }); - - test('should convert to JSON', () { - final result = PurchaseResult( - responseCode: 1, - debugMessage: 'Billing unavailable', - code: 'BILLING_UNAVAILABLE', - message: 'Billing is not available', - purchaseTokenAndroid: null, - ); - - final json = result.toJson(); - expect(json['responseCode'], 1); - expect(json['debugMessage'], 'Billing unavailable'); - expect(json['code'], 'BILLING_UNAVAILABLE'); - expect(json['message'], 'Billing is not available'); - expect(json['purchaseTokenAndroid'], ''); - }); - - test('toString should return formatted string', () { - final result = PurchaseResult( - responseCode: 7, - debugMessage: 'Item already owned', - code: 'ITEM_ALREADY_OWNED', - message: 'User already owns this item', - ); - - final stringResult = result.toString(); - expect(stringResult, contains('responseCode: 7')); - expect(stringResult, contains('debugMessage: Item already owned')); - expect(stringResult, contains('code: ITEM_ALREADY_OWNED')); - expect(stringResult, contains('message: User already owns this item')); - }); - }); - - group('ConnectionResult', () { - test('should create with message', () { - final result = ConnectionResult(msg: 'Connected successfully'); - expect(result.msg, 'Connected successfully'); - }); - - test('should create from JSON', () { - final json = {'msg': 'Connection established'}; - final result = ConnectionResult.fromJSON(json); - expect(result.msg, 'Connection established'); - }); - - test('should convert to JSON', () { - final result = ConnectionResult(msg: 'Ready to purchase'); - final json = result.toJson(); - expect(json['msg'], 'Ready to purchase'); - }); - - test('should handle null message in toJson', () { - final result = ConnectionResult(); - final json = result.toJson(); - expect(json['msg'], ''); - }); - - test('toString should return formatted string', () { - final result = ConnectionResult(msg: 'Service connected'); - expect(result.toString(), 'msg: Service connected'); - }); - }); - - group('ErrorCode enum', () { - test('should contain all expected error codes', () { - expect(ErrorCode.Unknown, isA()); - expect(ErrorCode.UserCancelled, isA()); - expect(ErrorCode.UserError, isA()); - expect(ErrorCode.ItemUnavailable, isA()); - expect(ErrorCode.ProductNotAvailable, isA()); - expect(ErrorCode.ProductAlreadyOwned, isA()); - expect(ErrorCode.ReceiptFinished, isA()); - expect(ErrorCode.AlreadyOwned, isA()); - expect(ErrorCode.NetworkError, isA()); - }); - }); - }); -} diff --git a/test/events_test.dart b/test/events_test.dart deleted file mode 100644 index aff8039ae..000000000 --- a/test/events_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_inapp_purchase/events.dart'; -import 'package:flutter_inapp_purchase/types.dart'; - -void main() { - group('Events', () { - test('IapEvent enum should contain expected values', () { - expect(IapEvent.purchaseUpdated, isA()); - expect(IapEvent.purchaseError, isA()); - expect(IapEvent.promotedProductIos, isA()); - }); - - test('PurchaseUpdatedEvent should create correctly', () { - final event = PurchaseUpdatedEvent( - purchase: Purchase( - productId: 'test_product', - platform: IapPlatform.android, - transactionId: 'test_transaction', - purchaseToken: 'test_token', - ), - ); - - expect(event.purchase.productId, 'test_product'); - expect(event.purchase.transactionId, 'test_transaction'); - }); - - test('PurchaseErrorEvent should create correctly', () { - final event = PurchaseErrorEvent( - error: PurchaseError( - code: ErrorCode.UserCancelled, - message: 'User cancelled the purchase', - platform: IapPlatform.android, - ), - ); - - expect(event.error.code, ErrorCode.UserCancelled); - expect(event.error.message, 'User cancelled the purchase'); - expect(event.error.platform, IapPlatform.android); - }); - - test('PromotedProductEvent should create correctly', () { - final event = PromotedProductEvent(productId: 'promoted_product_123'); - - expect(event.productId, 'promoted_product_123'); - }); - - test('ConnectionStateEvent should create correctly', () { - final connectedEvent = ConnectionStateEvent( - isConnected: true, - message: 'Connected successfully', - ); - expect(connectedEvent.isConnected, true); - expect(connectedEvent.message, 'Connected successfully'); - - final disconnectedEvent = ConnectionStateEvent( - isConnected: false, - message: 'Connection failed', - ); - expect(disconnectedEvent.isConnected, false); - expect(disconnectedEvent.message, 'Connection failed'); - }); - - test('EventSubscription should work correctly', () { - bool removed = false; - final subscription = EventSubscription(() { - removed = true; - }); - - expect(removed, false); - subscription.remove(); - expect(removed, true); - }); - }); -} diff --git a/test/extensions_test.dart b/test/extensions_test.dart deleted file mode 100644 index b77a33912..000000000 --- a/test/extensions_test.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_inapp_purchase/types.dart'; - -void main() { - group('Product and Purchase Type Tests', () { - group('Product Tests', () { - test('Product should be created correctly for Android', () { - final product = Product( - title: 'Test Product', - description: 'Test Description', - price: 9.99, - localizedPrice: '\$9.99', - currency: 'USD', - nameAndroid: 'Android Product', - ); - - expect(product.title, 'Test Product'); - expect(product.description, 'Test Description'); - expect(product.price, 9.99); - expect(product.localizedPrice, '\$9.99'); - expect(product.currency, 'USD'); - expect(product.nameAndroid, 'Android Product'); - }); - - test('Product should be created correctly for iOS', () { - final product = Product( - title: 'iOS Product', - description: 'iOS Description', - price: 4.99, - localizedPrice: '\$4.99', - currency: 'USD', - ); - - expect(product.title, 'iOS Product'); - expect(product.price, 4.99); - }); - }); - - group('Subscription Tests', () { - test('Subscription should be created correctly for Android', () { - final subscription = ProductSubscription( - title: 'Premium Subscription', - description: 'Premium features', - price: '9.99', - localizedPrice: '\$9.99', - currency: 'USD', - subscriptionPeriodAndroid: 'P1M', - platform: IapPlatform.android, - ); - - expect(subscription.subscriptionPeriodAndroid, 'P1M'); - expect(subscription.platform, 'android'); - }); - - test('Subscription should be created correctly for iOS', () { - final subscription = ProductSubscription( - title: 'Premium', - description: 'Premium subscription', - price: '9.99', - localizedPrice: '\$9.99', - currency: 'USD', - subscriptionGroupIdIOS: 'group_123', - platform: IapPlatform.ios, - ); - - expect(subscription.subscriptionGroupIdIOS, 'group_123'); - expect(subscription.platform, 'ios'); - }); - }); - - group('Purchase Tests', () { - test('Purchase should be created correctly for Android', () { - final purchase = Purchase( - productId: 'android_product', - transactionId: 'trans_123', - transactionDate: 1234567890, - purchaseToken: 'token_123', - platform: IapPlatform.android, - isAcknowledgedAndroid: true, - purchaseStateAndroid: 1, - ); - - expect(purchase.productId, 'android_product'); - expect(purchase.transactionId, 'trans_123'); - expect(purchase.id, 'trans_123'); - expect(purchase.platform, IapPlatform.android); - expect(purchase.isAcknowledgedAndroid, true); - expect(purchase.purchaseStateAndroid, 1); - }); - - test('Purchase should be created correctly for iOS', () { - final purchase = Purchase( - productId: 'ios_product', - transactionId: 'trans_456', - transactionDate: 1234567890, - purchaseToken: 'token_456', - platform: IapPlatform.ios, - transactionStateIOS: TransactionState.purchased, - originalTransactionIdentifierIOS: 'original_123', - ); - - expect(purchase.productId, 'ios_product'); - expect(purchase.transactionId, 'trans_456'); - expect(purchase.platform, IapPlatform.ios); - expect(purchase.transactionStateIOS, TransactionState.purchased); - expect(purchase.originalTransactionIdentifierIOS, 'original_123'); - }); - - test('Purchase id getter should return transactionId', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'test_trans_id', - platform: IapPlatform.android, - ); - - expect(purchase.id, 'test_trans_id'); - }); - - test( - 'Purchase id getter should return empty string when transactionId is null', - () { - final purchase = Purchase( - productId: 'test_product', - platform: IapPlatform.android, - ); - - expect(purchase.id, ''); - }, - ); - }); - - group('SubscriptionPurchase Tests', () { - test('SubscriptionPurchase should be created correctly', () { - final subPurchase = SubscriptionPurchase( - productId: 'sub_123', - transactionId: 'trans_123', - transactionDate: DateTime(2024, 1, 1).millisecondsSinceEpoch, - isActive: true, - expirationDate: DateTime(2024, 2, 1), - platform: IapPlatform.android, - ); - - expect(subPurchase.productId, 'sub_123'); - expect(subPurchase.transactionId, 'trans_123'); - expect(subPurchase.isActive, true); - expect(subPurchase.expirationDate, DateTime(2024, 2, 1)); - expect(subPurchase.platform, IapPlatform.android); - }); - - test('SubscriptionPurchase toJson should serialize correctly', () { - final subPurchase = SubscriptionPurchase( - productId: 'sub_123', - isActive: true, - expirationDate: DateTime(2024, 2, 1), - platform: IapPlatform.ios, - ); - - final json = subPurchase.toJson(); - - expect(json['productId'], 'sub_123'); - expect(json['isActive'], true); - expect(json['expirationDate'], contains('2024-02-01')); - expect(json['platform'], 'ios'); - }); - }); - - group('AppTransaction Tests', () { - test('AppTransaction should be created correctly', () { - final appTrans = AppTransaction( - appAppleId: '123456', - bundleId: 'com.example.app', - originalAppVersion: '1.0', - originalPurchaseDate: '2024-01-01', - ); - - expect(appTrans.appAppleId, '123456'); - expect(appTrans.bundleId, 'com.example.app'); - expect(appTrans.originalAppVersion, '1.0'); - expect(appTrans.originalPurchaseDate, '2024-01-01'); - }); - - test('AppTransaction fromJson should deserialize correctly', () { - final json = { - 'appAppleId': '123456', - 'bundleId': 'com.example.app', - 'originalAppVersion': '1.0', - }; - - final appTrans = AppTransaction.fromJson(json); - - expect(appTrans.appAppleId, '123456'); - expect(appTrans.bundleId, 'com.example.app'); - expect(appTrans.originalAppVersion, '1.0'); - }); - }); - - group('PurchaseIOS Tests', () { - test('PurchaseIOS should be created correctly', () { - final expirationDate = DateTime(2024, 2, 1); - final purchaseIOS = PurchaseIOS( - productId: 'ios_product', - transactionId: 'trans_123', - transactionDate: 1234567890, - expirationDateIOS: expirationDate, - transactionStateIOS: TransactionState.purchased, - ); - - expect(purchaseIOS.productId, 'ios_product'); - expect(purchaseIOS.transactionId, 'trans_123'); - expect(purchaseIOS.expirationDateIOS, expirationDate); - expect(purchaseIOS.platform, IapPlatform.ios); - expect(purchaseIOS.transactionStateIOS, TransactionState.purchased); - }); - }); - }); -} diff --git a/test/fetch_products_all_test.dart b/test/fetch_products_all_test.dart new file mode 100644 index 000000000..cd8e830ba --- /dev/null +++ b/test/fetch_products_all_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:flutter_inapp_purchase/types.dart' as types; +import 'package:platform/platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'fetchProducts': + return >[ + { + 'platform': 'ios', + 'id': 'premium_monthly', + 'type': 'subs', + 'title': 'Premium Monthly', + 'description': 'Monthly subscription', + 'currency': 'USD', + 'displayNameIOS': 'Premium Monthly', + 'displayPrice': '\$24.99', + 'isFamilyShareableIOS': false, + 'jsonRepresentationIOS': '{}', + 'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION', + 'price': 24.99, + }, + { + 'platform': 'ios', + 'id': 'coin_pack', + 'type': 'in-app', + 'title': 'Coin Pack', + 'description': 'One time coins', + 'currency': 'USD', + 'displayNameIOS': 'Coin Pack', + 'displayPrice': '\$2.99', + 'isFamilyShareableIOS': false, + 'jsonRepresentationIOS': '{}', + 'typeIOS': 'NON_CONSUMABLE', + 'price': 2.99, + }, + ]; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('fetchProducts detects per-item type when querying all products', + () async { + final platform = FakePlatform(operatingSystem: 'ios'); + final iap = FlutterInappPurchase.private(platform); + + await iap.initConnection(); + + final result = await iap.fetchProducts( + skus: const ['premium_monthly', 'coin_pack'], + type: types.ProductQueryType.All, + ); + + final subs = result.whereType().toList(); + final inApps = result.whereType().toList(); + + expect(subs, hasLength(1)); + expect(subs.first.id, 'premium_monthly'); + expect(inApps, hasLength(1)); + expect(inApps.first.id, 'coin_pack'); + }); +} diff --git a/test/flutter_inapp_purchase_active_subscriptions_test.dart b/test/flutter_inapp_purchase_active_subscriptions_test.dart new file mode 100644 index 000000000..37af4aa16 --- /dev/null +++ b/test/flutter_inapp_purchase_active_subscriptions_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:platform/platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('extractPurchases skips entries missing identifiers', () { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + final result = iap.extractPurchases( + >[ + { + 'platform': 'android', + 'purchaseStateAndroid': 1, + }, + ], + ); + + expect(result, isEmpty); + }); + + group('getActiveSubscriptions', () { + test('returns active Android subscriptions only for purchased items', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getAvailableItems': + return >[ + { + 'platform': 'android', + 'productId': 'sub.android', + 'transactionId': 'txn_android', + 'purchaseToken': 'token-123', + 'purchaseStateAndroid': 1, + 'isAutoRenewing': true, + 'autoRenewingAndroid': true, + 'quantity': 1, + 'transactionDate': 1700000000000, + }, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + await iap.initConnection(); + final subs = await iap.getActiveSubscriptions(); + + expect(subs, hasLength(1)); + expect(subs.single.productId, 'sub.android'); + expect(subs.single.autoRenewingAndroid, isTrue); + }); + + test('ignores deferred iOS purchases', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getAvailableItems': + return >[ + { + 'platform': 'ios', + 'productId': 'sub.ios', + 'transactionId': 'txn_ios', + 'purchaseToken': 'receipt-data', + 'purchaseState': 'DEFERRED', + 'transactionDate': 1700000000000, + }, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + final subs = await iap.getActiveSubscriptions(); + + expect(subs, isEmpty); + }); + + test('includes purchased iOS subscriptions', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getAvailableItems': + return >[ + { + 'platform': 'ios', + 'productId': 'sub.ios', + 'transactionId': 'txn_ios', + 'purchaseToken': 'receipt-data', + 'purchaseState': 'PURCHASED', + 'transactionDate': 1700000000000, + }, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + final subs = await iap.getActiveSubscriptions(); + + expect(subs, hasLength(1)); + expect(subs.single.productId, 'sub.ios'); + expect(subs.single.environmentIOS, isNull); + }); + }); +} diff --git a/test/flutter_inapp_purchase_test.dart b/test/flutter_inapp_purchase_test.dart deleted file mode 100644 index 77ba6bc14..000000000 --- a/test/flutter_inapp_purchase_test.dart +++ /dev/null @@ -1,974 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:platform/platform.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('FlutterInappPurchase', () { - late MethodChannel channel; - - setUpAll(() { - final iap = FlutterInappPurchase(); - channel = iap.channel; - }); - // Platform detection tests removed as getCurrentPlatform() uses Platform directly - // and cannot be properly mocked in tests - - group('initConnection', () { - group('for Android', () { - final List log = []; - late FlutterInappPurchase testIap; - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - return 'Billing service is ready'; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('invokes correct method', () async { - await testIap.initConnection(); - expect(log, [ - isMethodCall('initConnection', arguments: null), - ]); - }); - - test('returns correct result', () async { - final result = await testIap.initConnection(); - expect(result, true); - }); - }); - }); - - group('fetchProducts', () { - group('for Android', () { - final List log = []; - late FlutterInappPurchase testIap; - setUp(() async { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - if (methodCall.method == 'initConnection') { - return true; - } - // For requestProducts, Android expects parsed JSON list - if (methodCall.method == 'fetchProducts') { - return '''[ - { - "productId": "com.example.product1", - "price": "0.99", - "currency": "USD", - "localizedPrice": "\$0.99", - "title": "Product 1", - "description": "Description 1" - }, - { - "productId": "com.example.product2", - "price": "1.99", - "currency": "USD", - "localizedPrice": "\$1.99", - "title": "Product 2", - "description": "Description 2" - } - ]'''; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('invokes correct method', () async { - // Initialize connection first - await testIap.initConnection(); - log.clear(); // Clear init log - - await testIap.fetchProducts( - skus: ['com.example.product1', 'com.example.product2'], - type: ProductType.inapp, - ); - // Unified fetchProducts is used for product queries - expect(log, [ - isMethodCall( - 'fetchProducts', - arguments: { - 'skus': ['com.example.product1', 'com.example.product2'], - 'type': ProductType.inapp, - }, - ), - ]); - }); - - test('returns correct products', () async { - // Initialize connection first - await testIap.initConnection(); - - final products = await testIap.fetchProducts( - skus: ['com.example.product1', 'com.example.product2'], - type: ProductType.inapp, - ); - expect(products.length, 2); - expect(products[0].id, 'com.example.product1'); - expect(products[0].price, 0.99); - expect(products[0].currency, 'USD'); - }); - }); - }); - - group('fetchProducts for subscriptions', () { - group('for iOS', () { - final List log = []; - late FlutterInappPurchase testIap; - setUp(() async { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - if (methodCall.method == 'initConnection') { - return true; - } - return [ - { - 'productId': 'com.example.subscription1', - 'price': '9.99', - 'currency': 'USD', - 'localizedPrice': r'$9.99', - 'title': 'Subscription 1', - 'description': 'Monthly subscription', - 'subscriptionPeriodUnitIOS': 'MONTH', - 'subscriptionPeriodNumberIOS': '1', - }, - ]; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('invokes correct method', () async { - // Initialize connection first - await testIap.initConnection(); - log.clear(); // Clear init log - - await testIap.fetchProducts( - skus: ['com.example.subscription1'], - type: ProductType.subs, - ); - expect(log, [ - isMethodCall( - 'fetchProducts', - arguments: { - 'skus': ['com.example.subscription1'], - 'type': ProductType.subs, - }, - ), - ]); - }); - - test('returns correct subscriptions', () async { - // Initialize connection first - await testIap.initConnection(); - - final subscriptions = - await testIap.fetchProducts( - skus: ['com.example.subscription1'], - type: ProductType.subs, - ); - expect(subscriptions.length, 1); - expect(subscriptions[0].id, 'com.example.subscription1'); - // Now we can directly access Subscription properties - expect(subscriptions[0].subscriptionPeriodUnitIOS, 'MONTH'); - }); - }); - }); - - group('Error Handling', () { - test('PurchaseError creation from platform error', () { - final error = PurchaseError.fromPlatformError({ - 'code': 'E_USER_CANCELLED', - 'message': 'User cancelled the purchase', - 'responseCode': 1, - 'debugMessage': 'Debug info', - 'productId': 'com.example.product', - }, IapPlatform.android); - - expect(error.code, ErrorCode.UserCancelled); - expect(error.message, 'User cancelled the purchase'); - expect(error.responseCode, 1); - expect(error.debugMessage, 'Debug info'); - expect(error.productId, 'com.example.product'); - expect(error.platform, IapPlatform.android); - }); - - test('ErrorCodeUtils maps platform codes correctly', () { - // Test iOS mapping - expect( - ErrorCodeUtils.fromPlatformCode(2, IapPlatform.ios), - ErrorCode.UserCancelled, - ); - expect( - ErrorCodeUtils.toPlatformCode( - ErrorCode.UserCancelled, - IapPlatform.ios, - ), - 2, - ); - - // Test Android mapping - expect( - ErrorCodeUtils.fromPlatformCode( - 'E_USER_CANCELLED', - IapPlatform.android, - ), - ErrorCode.UserCancelled, - ); - expect( - ErrorCodeUtils.toPlatformCode( - ErrorCode.UserCancelled, - IapPlatform.android, - ), - 'E_USER_CANCELLED', - ); - - // Test unknown code - expect( - ErrorCodeUtils.fromPlatformCode('UNKNOWN_ERROR', IapPlatform.android), - ErrorCode.Unknown, - ); - }); - }); - - group('Product OpenIAP Compatibility', () { - test('Product has OpenIAP compliant id getter', () { - final product = Product(id: 'test.product.id', price: 9.99); - - expect(product.id, 'test.product.id'); - }); - - test('Price parsing tolerates numeric values in JSON', () { - // Subscription.fromJson with numeric price - final sub = ProductSubscription.fromJson({ - 'productId': 'com.example.sub', - 'price': 9.99, // numeric - 'platform': 'ios', - }); - expect(sub.price, 9.99); - - // ProductIOS.fromJson with numeric price - final pios = ProductIOS.fromJson({ - 'productId': 'com.example.inapp', - 'price': 2.49, // numeric - 'currency': 'USD', - 'localizedPrice': r'$2.49', - 'title': 'Inapp', - 'description': 'Desc', - 'type': 'inapp', - }); - expect(pios.price, 2.49); - - // DiscountIOS.fromJson with numeric price - final discount = DiscountIOS.fromJson({ - 'identifier': 'offer1', - 'type': 'PAYASYOUGO', - 'price': 1.99, // numeric - 'localizedPrice': r'$1.99', - 'paymentMode': 'PAYASYOUGO', - 'numberOfPeriods': '1', - 'subscriptionPeriod': 'MONTH', - }); - expect(discount.price, '1.99'); - - // ProductAndroid.fromJson with numeric price - final pand = ProductAndroid.fromJson({ - 'productId': 'com.example.android', - 'price': 4.5, // numeric - 'currency': 'USD', - 'localizedPrice': r'$4.50', - 'title': 'Android Product', - 'description': 'Desc', - 'type': 'inapp', - }); - expect(pand.price, 4.5); - }); - - test('Subscription has OpenIAP compliant id getter', () { - final subscription = ProductSubscription( - id: 'test.subscription.id', - price: '4.99', - platform: IapPlatform.ios, - ); - - expect(subscription.id, 'test.subscription.id'); - expect(subscription.id, 'test.subscription.id'); - }); - - test('Purchase has OpenIAP compliant id getter', () { - final purchase = Purchase( - productId: 'test.product', - transactionId: 'transaction123', - platform: IapPlatform.android, - ); - - expect(purchase.id, 'transaction123'); - expect(purchase.ids, ['test.product']); - }); - - test( - 'Purchase id getter returns empty string when transactionId is null', - () { - final purchase = Purchase( - productId: 'test.product', - transactionId: null, - platform: IapPlatform.ios, - ); - - expect(purchase.id, ''); - expect(purchase.ids, ['test.product']); - }, - ); - - test('Product toString includes new fields', () { - final product = Product( - id: 'test.product', - price: 9.99, - environmentIOS: 'Production', - subscriptionPeriodAndroid: 'P1M', - ); - - final str = product.toString(); - expect(str, contains('id: test.product')); - expect(str, contains('environmentIOS: Production')); - expect(str, contains('subscriptionPeriodAndroid: P1M')); - }); - - test('Purchase toString includes new fields', () { - final purchase = Purchase( - productId: 'test.product', - transactionId: 'trans123', - platform: IapPlatform.ios, - environmentIOS: 'Sandbox', - ); - - final str = purchase.toString(); - expect(str, contains('productId: test.product')); - expect(str, contains('id: "trans123"')); - expect(str, contains('environmentIOS: "Sandbox"')); - expect(str, isNot(contains('purchaseStateAndroid'))); - }); - }); - - group('Enum Values', () { - test('Store enum has correct values', () { - expect(Store.values.length, 4); - expect(Store.none.toString(), 'Store.none'); - expect(Store.playStore.toString(), 'Store.playStore'); - expect(Store.amazon.toString(), 'Store.amazon'); - expect(Store.appStore.toString(), 'Store.appStore'); - }); - - test('ProductType has correct values', () { - expect(ProductType.inapp, 'inapp'); - expect(ProductType.subs, 'subs'); - }); - - test('SubscriptionState enum has correct values', () { - expect(SubscriptionState.values.length, 5); - expect(SubscriptionState.active.toString(), 'SubscriptionState.active'); - expect( - SubscriptionState.expired.toString(), - 'SubscriptionState.expired', - ); - expect( - SubscriptionState.inBillingRetry.toString(), - 'SubscriptionState.inBillingRetry', - ); - expect( - SubscriptionState.inGracePeriod.toString(), - 'SubscriptionState.inGracePeriod', - ); - expect( - SubscriptionState.revoked.toString(), - 'SubscriptionState.revoked', - ); - }); - - // ProrationModeAndroid test removed - enum is in iap_android_types.dart - // and not directly exported from main library - }); - - group('getActiveSubscriptions', () { - group('for Android', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'getAvailableItems') { - // Return a mock subscription purchase - return '''[ - { - "productId": "monthly_subscription", - "transactionId": "GPA.1234-5678-9012-34567", - "transactionDate": ${DateTime.now().millisecondsSinceEpoch}, - "transactionReceipt": "receipt_data", - "purchaseToken": "token_123", - "autoRenewingAndroid": true, - "purchaseStateAndroid": 1, - "isAcknowledgedAndroid": true - } - ]'''; - } - return '[]'; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('returns active subscriptions', () async { - await testIap.initConnection(); - final subscriptions = await testIap.getActiveSubscriptions(); - - expect(subscriptions.length, 1); - expect(subscriptions.first.productId, 'monthly_subscription'); - expect(subscriptions.first.isActive, true); - expect(subscriptions.first.autoRenewingAndroid, true); - }); - - test('filters by subscription IDs', () async { - await testIap.initConnection(); - final subscriptions = await testIap.getActiveSubscriptions( - subscriptionIds: ['yearly_subscription'], - ); - - expect(subscriptions.length, 0); - }); - }); - - group('for iOS', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'getAvailableItems') { - // Return a mock iOS subscription purchase - return >[ - { - 'productId': 'monthly_subscription', - 'transactionId': '1000000123456789', - 'transactionDate': DateTime.now().millisecondsSinceEpoch, - 'transactionReceipt': 'receipt_data', - 'purchaseToken': - 'ios_jws_token_123', // Unified field for iOS JWS - 'jwsRepresentationIOS': - 'ios_jws_token_123', // Deprecated field - 'transactionStateIOS': - '1', // TransactionState.purchased value - 'environmentIOS': 'Production', - 'expirationDateIOS': DateTime.now() - .add(const Duration(days: 30)) - .millisecondsSinceEpoch, - }, - ]; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('returns active subscriptions with iOS-specific fields', () async { - await testIap.initConnection(); - final subscriptions = await testIap.getActiveSubscriptions(); - - expect(subscriptions.length, 1); - expect(subscriptions.first.productId, 'monthly_subscription'); - expect(subscriptions.first.isActive, true); - expect(subscriptions.first.environmentIOS, 'Production'); - expect(subscriptions.first.expirationDateIOS, isNotNull); - }); - }); - }); - - group('requestPurchase', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'requestPurchase') { - // Simulate purchase flow with unified purchaseToken - return { - 'productId': methodCall.arguments['sku'], - 'transactionId': 'GPA.test-transaction-123', - 'transactionDate': DateTime.now().millisecondsSinceEpoch, - 'transactionReceipt': 'test_receipt', - 'purchaseToken': 'unified_purchase_token_123', - 'purchaseTokenAndroid': 'unified_purchase_token_123', - 'signatureAndroid': 'test_signature', - 'purchaseStateAndroid': 1, - }; - } - if (methodCall.method == 'requestSubscription') { - // Simulate subscription flow with unified purchaseToken - return { - 'productId': methodCall.arguments['sku'], - 'transactionId': 'GPA.sub-transaction-456', - 'transactionDate': DateTime.now().millisecondsSinceEpoch, - 'transactionReceipt': 'test_subscription_receipt', - 'purchaseToken': 'unified_subscription_token_456', - 'purchaseTokenAndroid': 'unified_subscription_token_456', - 'autoRenewingAndroid': true, - 'purchaseStateAndroid': 1, - }; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test( - 'requestPurchase includes unified purchaseToken in response', - () async { - await testIap.initConnection(); - - // Request purchase will not directly return a PurchasedItem - // It triggers a native purchase flow that sends events - // For testing, we just verify the method can be called without error - await expectLater( - testIap.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['test.product']), - ), - type: ProductType.inapp, - ), - completes, - ); - }, - ); - - test( - 'requestSubscription includes unified purchaseToken in response', - () async { - await testIap.initConnection(); - - // Request subscription will not directly return a PurchasedItem - // It triggers a native purchase flow that sends events - // For testing, we just verify the method can be called without error - await expectLater( - testIap.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['test.subscription']), - ), - type: ProductType.subs, - ), - completes, - ); - }, - ); - }); - - group('requestPurchase for iOS', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'requestPurchase') { - // Simulate iOS purchase with JWS token - return { - 'productId': methodCall.arguments, - 'transactionId': '2000000123456789', - 'transactionDate': DateTime.now().millisecondsSinceEpoch, - 'transactionReceipt': 'ios_receipt_data', - 'purchaseToken': 'ios_jws_representation_token', - 'jwsRepresentationIOS': 'ios_jws_representation_token', - 'transactionStateIOS': '1', - }; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('iOS purchase includes unified purchaseToken (JWS)', () async { - await testIap.initConnection(); - - // Request purchase will not directly return a PurchasedItem - // It triggers a native purchase flow that sends events - // For testing, we just verify the method can be called without error - await expectLater( - testIap.requestPurchase( - request: RequestPurchase( - ios: RequestPurchaseIOS(sku: 'ios.test.product'), - ), - type: ProductType.inapp, - ), - completes, - ); - }); - }); - - group('getAvailablePurchases', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'getAvailableItems') { - // Return purchases with unified purchaseToken as JSON string - final timestamp = DateTime.now().millisecondsSinceEpoch; - return '''[ - { - "productId": "test.product.1", - "transactionId": "GPA.purchase-1", - "transactionDate": $timestamp, - "transactionReceipt": "receipt_1", - "purchaseToken": "unified_token_1", - "purchaseTokenAndroid": "unified_token_1", - "signatureAndroid": "signature_1", - "purchaseStateAndroid": 1, - "isAcknowledgedAndroid": true - }, - { - "productId": "test.product.2", - "transactionId": "GPA.purchase-2", - "transactionDate": $timestamp, - "transactionReceipt": "receipt_2", - "purchaseToken": "unified_token_2", - "purchaseTokenAndroid": "unified_token_2", - "signatureAndroid": "signature_2", - "purchaseStateAndroid": 1, - "isAcknowledgedAndroid": false - } - ]'''; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('returns available purchases with unified purchaseToken', () async { - await testIap.initConnection(); - final purchases = await testIap.getAvailablePurchases(); - - // Android uses unified getAvailableItems; mock returns 2 items - expect(purchases.length, 2); - - // Check that all purchases have unified purchaseToken - for (final purchase in purchases) { - expect(purchase.purchaseToken, isNotNull); - expect(purchase.purchaseToken, contains('unified_token')); - } - }); - }); - - group('getAvailablePurchases for iOS', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'getAvailableItems') { - // Return iOS purchases with JWS tokens - return >[ - { - 'productId': 'ios.product.1', - 'transactionId': '2000000111111111', - 'transactionDate': DateTime.now().millisecondsSinceEpoch, - 'transactionReceipt': 'ios_receipt_1', - 'purchaseToken': 'ios_jws_token_1', - 'jwsRepresentationIOS': 'ios_jws_token_1', - 'transactionStateIOS': '1', - }, - ]; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('iOS returns purchases with unified purchaseToken (JWS)', () async { - await testIap.initConnection(); - final purchases = await testIap.getAvailablePurchases(); - - expect(purchases.length, 1); - expect(purchases[0].productId, 'ios.product.1'); - expect(purchases[0].purchaseToken, 'ios_jws_token_1'); - expect(purchases[0].transactionStateIOS, TransactionState.purchased); - }); - }); - - group('finishTransaction', () { - late FlutterInappPurchase testIap; - - group('on Android', () { - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'consumeProduct') { - expect(methodCall.arguments['purchaseToken'], isNotNull); - // Return JSON response like actual Android implementation - return '{"responseCode":0,"debugMessage":"","code":"OK","message":"","purchaseToken":"${methodCall.arguments['purchaseToken']}"}'; - } - if (methodCall.method == 'acknowledgePurchase') { - expect(methodCall.arguments['purchaseToken'], isNotNull); - // Return JSON response like actual Android implementation - return '{"responseCode":0,"debugMessage":"","code":"OK","message":""}'; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('consumes consumable purchases on Android', () async { - await testIap.initConnection(); - - final purchase = Purchase( - productId: 'consumable.product', - transactionId: 'GPA.consume-123', - purchaseToken: 'consume_token_123', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await testIap.finishTransaction(purchase, isConsumable: true); - // Test passes if consumeProduct was called - }); - - test('acknowledges non-consumable purchases on Android', () async { - await testIap.initConnection(); - - final purchase = Purchase( - productId: 'non_consumable.product', - transactionId: 'GPA.acknowledge-456', - purchaseToken: 'acknowledge_token_456', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await testIap.finishTransaction(purchase, isConsumable: false); - // Test passes if acknowledgePurchase was called - }); - - test('skips already acknowledged purchases on Android', () async { - await testIap.initConnection(); - - final purchase = Purchase( - productId: 'already_acknowledged.product', - transactionId: 'GPA.ack-789', - purchaseToken: 'ack_token_789', - platform: IapPlatform.android, - isAcknowledgedAndroid: true, - ); - - await testIap.finishTransaction(purchase, isConsumable: false); - // Should not call acknowledgePurchase since already acknowledged - }); - }); - - group('on iOS', () { - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'finishTransaction') { - // Allow null transactionId for edge case testing - return 'finished'; - } - return null; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('finishes transaction on iOS using id field', () async { - await testIap.initConnection(); - - final purchase = Purchase( - productId: 'ios.product', - transactionId: '2000000123456', - platform: IapPlatform.ios, - ); - - await testIap.finishTransaction(purchase); - // Test passes if finishTransaction was called with transactionId - }); - - test('finishes transaction on iOS when id is empty', () async { - await testIap.initConnection(); - - final purchase = Purchase( - productId: 'ios.product', - transactionId: null, - platform: IapPlatform.ios, - ); - - // When transactionId is null, finishTransaction should still be called - // but with null transactionId. Test that it doesn't throw an error. - await expectLater(testIap.finishTransaction(purchase), completes); - }); - }); - }); - - group('hasActiveSubscriptions', () { - late FlutterInappPurchase testIap; - - setUp(() { - testIap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'initConnection') { - return 'Billing service is ready'; - } - if (methodCall.method == 'getAvailableItems') { - return '''[ - { - "productId": "monthly_subscription", - "transactionId": "GPA.1234-5678-9012-34567", - "transactionDate": ${DateTime.now().millisecondsSinceEpoch}, - "transactionReceipt": "receipt_data", - "purchaseToken": "token_123", - "autoRenewingAndroid": true, - "purchaseStateAndroid": 1, - "isAcknowledgedAndroid": true - } - ]'''; - } - return '[]'; - }); - }); - - tearDown(() { - channel.setMethodCallHandler(null); - }); - - test('returns true when user has active subscriptions', () async { - await testIap.initConnection(); - final hasSubscriptions = await testIap.hasActiveSubscriptions(); - - expect(hasSubscriptions, true); - }); - - test( - 'returns false when filtering for non-existent subscription', - () async { - await testIap.initConnection(); - final hasSubscriptions = await testIap.hasActiveSubscriptions( - subscriptionIds: ['non_existent_subscription'], - ); - - expect(hasSubscriptions, false); - }, - ); - - test('returns true when filtering for existing subscription', () async { - await testIap.initConnection(); - final hasSubscriptions = await testIap.hasActiveSubscriptions( - subscriptionIds: ['monthly_subscription'], - ); - - expect(hasSubscriptions, true); - }); - }); - }); -} diff --git a/test/openiap_type_alignment_test.dart b/test/openiap_type_alignment_test.dart index dda415711..8197a3667 100644 --- a/test/openiap_type_alignment_test.dart +++ b/test/openiap_type_alignment_test.dart @@ -1,286 +1,104 @@ +// ignore_for_file: prefer_const_constructors + import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_inapp_purchase/types.dart'; void main() { - group('OpenIAP Type Alignment Tests', () { - group('Product Type Alignment', () { - test('Product should have OpenIAP compliant id getter', () { - final product = Product( - id: 'test_product', - priceString: '9.99', - platformEnum: IapPlatform.android, - ); - - // OpenIAP spec: Product should have id field that maps to productId - expect(product.id, 'test_product'); - }); - - test('Product should have required OpenIAP fields', () { - final product = Product( - id: 'test_product', - title: 'Test Product', - description: 'Test Description', - type: 'inapp', - displayPrice: '\$9.99', - currency: 'USD', - price: 9.99, - platformEnum: IapPlatform.android, - ); - - expect(product.id, isA()); - expect(product.title, isA()); - expect(product.description, isA()); - expect(product.type, isA()); - expect(product.displayPrice, isA()); - expect(product.currency, isA()); - expect(product.price, isA()); - }); - - test('Android Product should have platform-specific fields', () { - final product = Product( - id: 'android_product', - priceString: '9.99', - platformEnum: IapPlatform.android, - nameAndroid: 'Android Product Name', - oneTimePurchaseOfferDetailsAndroid: { - 'priceAmountMicros': 9990000, - 'priceCurrencyCode': 'USD', - 'formattedPrice': '\$9.99', - }, - ); - - expect(product.nameAndroid, isA()); - expect( - product.oneTimePurchaseOfferDetailsAndroid, - isA?>(), - ); - expect(product.platformEnum, IapPlatform.android); - }); - - test('iOS Product should have platform-specific fields', () { - final product = Product( - id: 'ios_product', - priceString: '9.99', - platformEnum: IapPlatform.ios, - environmentIOS: 'Sandbox', - subscriptionGroupIdIOS: 'group_123', - promotionalOfferIdsIOS: ['offer1', 'offer2'], - ); - - expect(product.environmentIOS, isA()); - expect(product.subscriptionGroupIdIOS, isA()); - expect(product.promotionalOfferIdsIOS, isA?>()); - expect(product.platformEnum, IapPlatform.ios); - }); + group('Generated type smoke tests', () { + test('ProductAndroid exposes core pricing fields', () { + const product = ProductAndroid( + currency: 'USD', + description: 'Monthly access', + displayPrice: '\$9.99', + id: 'monthly_access', + nameAndroid: 'Monthly Access', + platform: IapPlatform.Android, + price: 9.99, + title: 'Monthly Access', + type: ProductType.InApp, + ); + + expect(product.id, 'monthly_access'); + expect(product.platform, IapPlatform.Android); + expect(product.price, 9.99); }); - group('Subscription Type Alignment', () { - test('Subscription should have OpenIAP compliant id and ids getters', () { - final subscription = ProductSubscription( - id: 'test_sub', - price: '9.99', - platform: IapPlatform.android, - ); - - // OpenIAP spec: Subscription should have id field that maps to productId - expect(subscription.id, 'test_sub'); - expect(subscription.ids, ['test_sub']); - }); - - test('Android Subscription should have offer details structure', () { - final subscription = ProductSubscription( - id: 'android_sub', - price: '9.99', - platform: IapPlatform.android, - subscriptionOfferDetailsAndroid: [ - OfferDetail( - basePlanId: 'monthly', - offerId: 'intro_offer', - pricingPhases: [ - PricingPhase( - priceAmount: 0.99, - price: '0.99', - currency: 'USD', - billingPeriod: 'P1M', - billingCycleCount: 3, - recurrenceMode: RecurrenceMode.finiteRecurring, - ), - ], - ), - ], - ); - - expect( - subscription.subscriptionOfferDetailsAndroid, - isA?>(), - ); - final offer = subscription.subscriptionOfferDetailsAndroid!.first; - expect(offer.basePlanId, 'monthly'); - expect(offer.offerId, 'intro_offer'); - expect(offer.pricingPhases, isA>()); - }); - }); - - group('Purchase Type Alignment', () { - test('Purchase should have OpenIAP compliant id and ids getters', () { - final purchase = Purchase( - productId: 'test_product', - platform: IapPlatform.android, - transactionId: 'txn_123', - ); - - // OpenIAP spec: Purchase should have id field for transaction identifier - expect(purchase.id, 'txn_123'); - expect(purchase.ids, ['test_product']); - }); - - test('Android Purchase should have platform-specific fields', () { - final purchase = Purchase( - productId: 'android_product', - platform: IapPlatform.android, - transactionId: 'GPA.123456789', - purchaseToken: 'android_token', - dataAndroid: '{"test": "data"}', - signatureAndroid: 'signature_123', - purchaseStateAndroid: 1, - isAcknowledgedAndroid: true, - packageNameAndroid: 'com.example.app', - obfuscatedAccountIdAndroid: 'account_123', - ); - - expect(purchase.dataAndroid, isA()); - expect(purchase.signatureAndroid, isA()); - expect(purchase.purchaseStateAndroid, isA()); - expect(purchase.isAcknowledgedAndroid, isA()); - expect(purchase.packageNameAndroid, isA()); - expect(purchase.obfuscatedAccountIdAndroid, isA()); - }); - - test('iOS Purchase should have platform-specific fields', () { - final purchase = Purchase( - productId: 'ios_product', - platform: IapPlatform.ios, - transactionId: '2000000123456789', - quantityIOS: 1, - originalTransactionDateIOS: '2023-08-20T10:00:00Z', - originalTransactionIdentifierIOS: '2000000000000001', - environmentIOS: 'Production', - currencyCodeIOS: 'USD', - priceIOS: 9.99, - appBundleIdIOS: 'com.example.ios.app', - productTypeIOS: 'Auto-Renewable Subscription', - transactionReasonIOS: 'PURCHASE', - ); - - expect(purchase.quantityIOS, isA()); - expect(purchase.originalTransactionDateIOS, isA()); - expect(purchase.originalTransactionIdentifierIOS, isA()); - expect(purchase.environmentIOS, isA()); - expect(purchase.currencyCodeIOS, isA()); - expect(purchase.priceIOS, isA()); - expect(purchase.appBundleIdIOS, isA()); - expect(purchase.productTypeIOS, isA()); - expect(purchase.transactionReasonIOS, isA()); - }); + test('ProductIOS exposes StoreKit metadata', () { + const product = ProductIOS( + currency: 'USD', + description: 'Premium upgrade', + displayNameIOS: 'Premium', + displayPrice: '\$4.99', + id: 'premium_upgrade', + isFamilyShareableIOS: true, + jsonRepresentationIOS: '{}', + platform: IapPlatform.IOS, + title: 'Premium Upgrade', + type: ProductType.Subs, + typeIOS: ProductTypeIOS.AutoRenewableSubscription, + ); + + expect(product.type, ProductType.Subs); + expect(product.typeIOS, ProductTypeIOS.AutoRenewableSubscription); + expect(product.platform, IapPlatform.IOS); }); - group('Pricing and Offer Type Alignment', () { - test('PricingPhase should match OpenIAP structure', () { - final phase = PricingPhase( - priceAmount: 9.99, - price: '9.99', - currency: 'USD', - billingPeriod: 'P1M', - billingCycleCount: 1, - recurrenceMode: RecurrenceMode.infiniteRecurring, - ); - - expect(phase.priceAmount, isA()); - expect(phase.price, isA()); - expect(phase.currency, isA()); - expect(phase.billingPeriod, isA()); - expect(phase.billingCycleCount, isA()); - expect(phase.recurrenceMode, isA()); - }); - - test('OfferDetail should have nested pricingPhases structure', () { - final offer = OfferDetail( - basePlanId: 'monthly_base', - offerId: 'intro_offer', - pricingPhases: [ - PricingPhase( - priceAmount: 0.99, - price: '0.99', - currency: 'USD', - billingPeriod: 'P1W', - ), - ], - ); - - final json = offer.toJson(); - - // Should match TypeScript structure with nested pricingPhases - expect(json['basePlanId'], 'monthly_base'); - expect(json['offerId'], 'intro_offer'); - expect(json['pricingPhases'], isA>()); - expect(json['pricingPhases']['pricingPhaseList'], isA>()); - - final phases = - json['pricingPhases']['pricingPhaseList'] as List; - expect(phases.length, 1); - - final phase = phases.first as Map; - expect(phase['priceAmountMicros'], isA()); - expect(phase['formattedPrice'], '0.99'); - expect(phase['priceCurrencyCode'], 'USD'); - expect(phase['billingPeriod'], 'P1W'); - }); + test('PurchaseAndroid stores purchase token and platform data', () { + const purchase = PurchaseAndroid( + id: 'txn_android', + isAutoRenewing: true, + platform: IapPlatform.Android, + productId: 'monthly_access', + purchaseState: PurchaseState.Purchased, + purchaseToken: 'token_123', + quantity: 1, + transactionDate: 1700000000, + ); + + expect(purchase.id, 'txn_android'); + expect(purchase.purchaseToken, 'token_123'); + expect(purchase.platform, IapPlatform.Android); }); - group('Error Code Type Alignment', () { - test('ErrorCode enum should match OpenIAP specification', () { - // Test that our ErrorCode enum has the expected OpenIAP values - expect(ErrorCode.Unknown, isA()); - expect(ErrorCode.UserCancelled, isA()); - expect(ErrorCode.UserError, isA()); - expect(ErrorCode.ItemUnavailable, isA()); - expect(ErrorCode.ProductNotAvailable, isA()); - expect(ErrorCode.ProductAlreadyOwned, isA()); - expect(ErrorCode.NetworkError, isA()); - expect(ErrorCode.AlreadyOwned, isA()); - }); - - test('PurchaseError should have OpenIAP compliant structure', () { - final error = PurchaseError( - code: ErrorCode.UserCancelled, - message: 'User cancelled the purchase', - platform: IapPlatform.android, - responseCode: 6, - ); - - expect(error.code, isA()); - expect(error.message, isA()); - expect(error.platform, isA()); - expect(error.responseCode, isA()); - }); + test('PurchaseIOS stores StoreKit specific fields', () { + final purchase = PurchaseIOS( + id: 'txn_ios', + isAutoRenewing: false, + platform: IapPlatform.IOS, + productId: 'premium_upgrade', + purchaseState: PurchaseState.Purchased, + quantity: 1, + transactionDate: 1700000100, + environmentIOS: 'Sandbox', + quantityIOS: 1, + ); + + expect(purchase.environmentIOS, 'Sandbox'); + expect(purchase.quantityIOS, 1); + expect(purchase.platform, IapPlatform.IOS); }); - group('Platform Enum Alignment', () { - test('IapPlatform should have correct string representations', () { - expect(IapPlatform.android.toString(), contains('android')); - expect(IapPlatform.ios.toString(), contains('ios')); - }); - - test('Product types should be OpenIAP compliant', () { - expect(ProductType.inapp, 'inapp'); - expect(ProductType.subs, 'subs'); - }); - - test('RecurrenceMode should match OpenIAP enum', () { - expect(RecurrenceMode.infiniteRecurring, isA()); - expect(RecurrenceMode.finiteRecurring, isA()); - expect(RecurrenceMode.nonRecurring, isA()); - }); + test('PricingPhasesAndroid round-trips through JSON', () { + const phases = PricingPhasesAndroid( + pricingPhaseList: [ + PricingPhaseAndroid( + billingCycleCount: 3, + billingPeriod: 'P1M', + formattedPrice: '\$0.99', + priceAmountMicros: '990000', + priceCurrencyCode: 'USD', + recurrenceMode: 1, + ), + ], + ); + + final json = phases.toJson(); + final restored = PricingPhasesAndroid.fromJson({ + 'pricingPhaseList': json['pricingPhaseList'] as List, + }); + + expect(restored.pricingPhaseList.length, 1); + expect(restored.pricingPhaseList.first.priceCurrencyCode, 'USD'); }); }); } diff --git a/test/proration_mode_validation_test.dart b/test/proration_mode_validation_test.dart deleted file mode 100644 index a8cda376a..000000000 --- a/test/proration_mode_validation_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('Proration Mode Validation Tests', () { - test( - 'requestSubscription throws error when using deferred proration without purchaseToken', - () { - // Test that using deferred proration mode without purchaseToken throws an error - expect( - () => RequestSubscriptionAndroid( - skus: ['test_subscription'], - replacementModeAndroid: 4, // DEFERRED mode - subscriptionOffers: [], - // Missing purchaseTokenAndroid - should trigger assert in debug mode - ), - throwsAssertionError, - ); - }, - ); - - test( - 'requestSubscription allows deferred proration with purchaseToken', - () { - // Test that using deferred proration mode with purchaseToken works - final request = RequestSubscriptionAndroid( - skus: ['test_subscription'], - replacementModeAndroid: 4, // DEFERRED mode - purchaseTokenAndroid: 'valid_purchase_token', - subscriptionOffers: [], - ); - - expect(request.skus, ['test_subscription']); - expect(request.replacementModeAndroid, 4); - expect(request.purchaseTokenAndroid, 'valid_purchase_token'); - }, - ); - - test('requestSubscription works without proration mode', () { - // Test that normal subscription request without proration works - final request = RequestSubscriptionAndroid( - skus: ['test_subscription'], - subscriptionOffers: [], - ); - - expect(request.skus, ['test_subscription']); - expect(request.replacementModeAndroid, null); - expect(request.purchaseTokenAndroid, null); - }); - - test('requestSubscription works with proration mode -1 (default)', () { - // Test that using default value (-1) doesn't require purchaseToken - final request = RequestSubscriptionAndroid( - skus: ['test_subscription'], - replacementModeAndroid: -1, - subscriptionOffers: [], - ); - - expect(request.skus, ['test_subscription']); - expect(request.replacementModeAndroid, -1); - }); - - test( - 'requestSubscription throws for all proration modes without purchaseToken', - () { - // Test all Android proration modes that require purchaseToken - final prorationModes = [ - 1, // IMMEDIATE_WITH_TIME_PRORATION - 2, // IMMEDIATE_WITHOUT_PRORATION - 3, // IMMEDIATE_AND_CHARGE_PRORATED_PRICE - 4, // DEFERRED - 5, // IMMEDIATE_AND_CHARGE_FULL_PRICE - ]; - - for (final mode in prorationModes) { - expect( - () => RequestSubscriptionAndroid( - skus: ['test_subscription'], - replacementModeAndroid: mode, - subscriptionOffers: [], - // Missing purchaseTokenAndroid - ), - throwsAssertionError, - reason: 'Proration mode $mode should require purchaseToken', - ); - } - }, - ); - - test( - 'requestSubscription works for all proration modes with purchaseToken', - () { - // Test all Android proration modes work with purchaseToken - final prorationModes = [ - 1, // IMMEDIATE_WITH_TIME_PRORATION - 2, // IMMEDIATE_WITHOUT_PRORATION - 3, // IMMEDIATE_AND_CHARGE_PRORATED_PRICE - 4, // DEFERRED - 5, // IMMEDIATE_AND_CHARGE_FULL_PRICE - ]; - - for (final mode in prorationModes) { - final request = RequestSubscriptionAndroid( - skus: ['test_subscription'], - replacementModeAndroid: mode, - purchaseTokenAndroid: 'valid_token', - subscriptionOffers: [], - ); - - expect(request.replacementModeAndroid, mode); - expect(request.purchaseTokenAndroid, 'valid_token'); - } - }, - ); - }); -} diff --git a/test/purchase_flow_test.dart b/test/purchase_flow_test.dart deleted file mode 100644 index 4554dc22e..000000000 --- a/test/purchase_flow_test.dart +++ /dev/null @@ -1,561 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:platform/platform.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('Purchase Flow Tests', () { - late FlutterInappPurchase plugin; - final List methodChannelLog = []; - - setUp(() { - methodChannelLog.clear(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - methodChannelLog.add(methodCall); - switch (methodCall.method) { - case 'initConnection': - return true; - case 'endConnection': - return true; - case 'fetchProducts': - final args = methodCall.arguments as Map?; - final productIds = - (args?['skus'] as Iterable?)?.map((e) => e.toString()).toList(); - final allProducts = _getMockProducts(); - final filteredProducts = productIds != null - ? allProducts - .where( - (product) => productIds.contains(product['productId']), - ) - .toList() - : allProducts; - return filteredProducts - .map((item) => Map.from(item)) - .toList(); - case 'requestPurchase': - return _getMockPurchase(methodCall.arguments); - case 'requestSubscription': - return _getMockSubscription(methodCall.arguments); - case 'finishTransaction': - return 'finished'; - case 'consumePurchaseAndroid': - return {'purchaseToken': methodCall.arguments}; - case 'acknowledgePurchaseAndroid': - return {'purchaseToken': methodCall.arguments}; - case 'getAvailablePurchases': - return _getMockAvailablePurchases() - .map((item) => Map.from(item)) - .toList(); - case 'getAvailableItems': - return _getMockAvailablePurchases() - .map((item) => Map.from(item)) - .toList(); - case 'restorePurchases': - return _getMockAvailablePurchases() - .map((item) => Map.from(item)) - .toList(); - case 'getPurchaseHistory': - return _getMockPurchaseHistory() - .map((item) => Map.from(item)) - .toList(); - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), null); - }); - - group('Connection Management', () { - test('initConnection succeeds on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - final result = await plugin.initConnection(); - expect(result, true); - expect(methodChannelLog.last.method, 'initConnection'); - }); - - test('initConnection succeeds on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - final result = await plugin.initConnection(); - expect(result, true); - expect(methodChannelLog.last.method, 'initConnection'); - }); - - test('initConnection throws when already initialized', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - expect( - () => plugin.initConnection(), - throwsA( - isA().having( - (e) => e.code, - 'error code', - ErrorCode.AlreadyInitialized, - ), - ), - ); - }); - - test('endConnection succeeds when initialized', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - final result = await plugin.endConnection(); - expect(result, true); - expect(methodChannelLog.last.method, 'endConnection'); - }); - - test('endConnection returns false when not initialized', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - final result = await plugin.endConnection(); - expect(result, false); - }); - }); - - group('Product Requests', () { - test('fetchProducts returns products on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final products = await plugin.fetchProducts( - skus: ['product1', 'product2'], - type: ProductType.inapp, - ); - - expect(products.length, 2); - expect(products[0].id, 'product1'); - expect(products[0].displayPrice, '\$1.99'); - expect(products[1].id, 'product2'); - }); - - test('fetchProducts throws when not initialized', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - - expect( - () => plugin.fetchProducts( - skus: ['product1'], - type: ProductType.inapp, - ), - throwsA( - isA().having( - (e) => e.code, - 'error code', - ErrorCode.NotInitialized, - ), - ), - ); - }); - - test('fetchProducts for subscriptions', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final subscriptions = await plugin.fetchProducts( - skus: ['sub1', 'sub2'], - type: ProductType.subs, - ); - - expect(subscriptions.length, 2); - expect(methodChannelLog.last.method, 'fetchProducts'); - }); - }); - - group('Purchase Requests', () { - test('requestPurchase for product on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['product1']), - ), - type: ProductType.inapp, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - }); - - test('requestPurchase with additional params on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid( - skus: ['product1'], - obfuscatedAccountIdAndroid: 'user123', - obfuscatedProfileIdAndroid: 'profile456', - ), - ), - type: ProductType.inapp, - ); - - expect( - methodChannelLog.last.arguments['sku'] ?? - methodChannelLog.last.arguments['productId'], - 'product1', - ); - expect( - methodChannelLog.last.arguments['obfuscatedAccountId'], - 'user123', - ); - expect( - methodChannelLog.last.arguments['obfuscatedProfileId'], - 'profile456', - ); - }); - - test('requestPurchase for subscription on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['subscription1']), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - }); - - test('requestPurchase with offer token on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestSubscriptionAndroid( - skus: ['subscription1'], - subscriptionOffers: [ - SubscriptionOfferAndroid( - sku: 'subscription1', - offerToken: 'offer_token_123', - ), - ], - ), - ), - type: ProductType.subs, - ); - - // Check that subscription1 is in the arguments - expect( - methodChannelLog.last.arguments.toString(), - contains('subscription1'), - ); - }); - - test('requestPurchase on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase(ios: RequestPurchaseIOS(sku: 'ios.product')), - type: ProductType.inapp, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - }); - }); - - group('Transaction Completion', () { - test('finishTransaction consumes consumable on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'consumable.product', - transactionId: 'GPA.1234', - purchaseToken: 'consume_token', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await plugin.finishTransaction(purchase, isConsumable: true); - - expect(methodChannelLog.last.method, 'consumePurchaseAndroid'); - }); - - test( - 'finishTransaction acknowledges non-consumable on Android', - () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'non_consumable.product', - transactionId: 'GPA.5678', - purchaseToken: 'acknowledge_token', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'acknowledgePurchaseAndroid'); - }, - ); - - test('finishTransaction on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'ios.product', - transactionId: '1000000123456', - platform: IapPlatform.ios, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'finishTransaction'); - expect( - methodChannelLog.last.arguments['transactionId'], - '1000000123456', - ); - }); - - test('finishTransaction skips already acknowledged', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'already_ack.product', - transactionId: 'GPA.9999', - purchaseToken: 'ack_token', - platform: IapPlatform.android, - isAcknowledgedAndroid: true, - ); - - await plugin.finishTransaction(purchase); - - // Should not call any method since already acknowledged - expect( - methodChannelLog.isEmpty || - methodChannelLog.last.method == 'initConnection', - true, - ); - }); - }); - - group('Purchase History', () { - test('getAvailablePurchases returns purchases', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchases = await plugin.getAvailablePurchases(); - - // The mock returns all available purchases - expect(purchases.length, greaterThan(0)); - // Just verify we have some purchases - expect(purchases[0].productId, isNotEmpty); - }); - }); - - group('Error Handling', () { - test('handles purchase cancellation', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'requestPurchase') { - throw PlatformException( - code: 'E_USER_CANCELLED', - message: 'User cancelled the purchase', - ); - } - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - expect( - () => plugin.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['product1']), - ), - type: ProductType.inapp, - ), - throwsA(isA()), - ); - }); - - test('handles network errors', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'fetchProducts') { - throw PlatformException( - code: 'E_NETWORK_ERROR', - message: 'Network connection failed', - ); - } - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - expect( - () => plugin.fetchProducts( - skus: ['product1'], - type: ProductType.inapp, - ), - throwsA(isA()), - ); - }); - }); - }); -} - -List> _getMockProducts() { - return >[ - { - 'productId': 'product1', - 'price': '\$1.99', - 'currency': 'USD', - 'localizedPrice': '\$1.99', - 'title': 'Product 1', - 'description': 'Description for product 1', - }, - { - 'productId': 'product2', - 'price': '\$2.99', - 'currency': 'USD', - 'localizedPrice': '\$2.99', - 'title': 'Product 2', - 'description': 'Description for product 2', - }, - { - 'productId': 'sub1', - 'price': '\$4.99', - 'currency': 'USD', - 'localizedPrice': '\$4.99', - 'title': 'Subscription 1', - 'description': 'Monthly subscription', - }, - { - 'productId': 'sub2', - 'price': '\$9.99', - 'currency': 'USD', - 'localizedPrice': '\$9.99', - 'title': 'Subscription 2', - 'description': 'Yearly subscription', - }, - ]; -} - -Map _getMockPurchase(dynamic arguments) { - final args = arguments as Map?; - return { - 'productId': args?['productId'] ?? args?['sku'] ?? 'product1', - 'purchaseToken': 'mock_purchase_token', - 'orderId': 'ORDER123', - 'transactionId': 'GPA.1234-5678', - 'purchaseState': 1, - 'isAcknowledged': false, - }; -} - -Map _getMockSubscription(dynamic arguments) { - final args = arguments as Map?; - return { - 'productId': args?['sku'] ?? 'subscription1', - 'purchaseToken': 'mock_subscription_token', - 'orderId': 'SUB_ORDER123', - 'transactionId': 'GPA.SUB-1234', - 'purchaseState': 1, - 'isAcknowledged': false, - 'autoRenewing': true, - }; -} - -List> _getMockAvailablePurchases() { - return >[ - { - 'productId': 'past_product1', - 'purchaseToken': 'past_token1', - 'transactionId': 'GPA.PAST-001', - }, - { - 'productId': 'past_product2', - 'purchaseToken': 'past_token2', - 'transactionId': 'GPA.PAST-002', - }, - ]; -} - -List> _getMockPurchaseHistory() { - return >[ - { - 'productId': 'history_product1', - 'purchaseToken': 'history_token1', - 'transactionId': 'GPA.HIST-001', - 'transactionDate': '2024-01-01T10:00:00Z', - }, - { - 'productId': 'history_product2', - 'purchaseToken': 'history_token2', - 'transactionId': 'GPA.HIST-002', - 'transactionDate': '2024-01-02T10:00:00Z', - }, - { - 'productId': 'history_product3', - 'purchaseToken': 'history_token3', - 'transactionId': 'GPA.HIST-003', - 'transactionDate': '2024-01-03T10:00:00Z', - }, - ]; -} diff --git a/test/refactored_types_test.dart b/test/refactored_types_test.dart index 6828d0aa4..36aee499f 100644 --- a/test/refactored_types_test.dart +++ b/test/refactored_types_test.dart @@ -1,235 +1,87 @@ -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +// ignore_for_file: prefer_const_constructors + import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase/types.dart'; void main() { - group('Product Type Tests', () { - test('Product should be created with basic fields', () { - final product = Product( - id: 'test_product', - title: 'Test Product', - description: 'Test Description', - price: 9.99, - localizedPrice: '\$9.99', - currency: 'USD', - platformEnum: IapPlatform.ios, + group('ActiveSubscription serialization', () { + test('round-trips iOS metadata', () { + const subscription = ActiveSubscription( + isActive: true, + productId: 'premium_sub', + transactionDate: 1700000200, + transactionId: 'txn_123', + environmentIOS: 'Sandbox', + expirationDateIOS: 1700001200, + willExpireSoon: true, ); - expect(product.id, 'test_product'); - expect(product.title, 'Test Product'); - expect(product.description, 'Test Description'); - expect(product.price, 9.99); - expect(product.localizedPrice, '\$9.99'); - expect(product.currency, 'USD'); - }); + final json = subscription.toJson(); + final restored = ActiveSubscription.fromJson(json); - test('Product should handle iOS-specific fields', () { - final product = Product( - id: 'test_product', - title: 'Test Product', - description: 'Test Description', - price: 9.99, - localizedPrice: '\$9.99', - currency: 'USD', - platformEnum: IapPlatform.ios, - discountsIOS: [], - subscription: null, - ); - - expect(product.discountsIOS, isNotNull); - expect(product.discountsIOS, isEmpty); - }); - - test('Subscription should extend ProductCommon', () { - final subscription = ProductSubscription( - id: 'test_subscription', - title: 'Test Subscription', - description: 'Test Subscription Description', - price: '4.99', - localizedPrice: '\$4.99', - currency: 'USD', - platform: IapPlatform.ios, - subscriptionPeriodAndroid: 'P1M', - subscriptionGroupIdIOS: 'test_group', - ); - - expect(subscription, isA()); - expect(subscription.id, 'test_subscription'); - expect(subscription.subscriptionPeriodAndroid, 'P1M'); - expect(subscription.subscriptionGroupIdIOS, 'test_group'); + expect(restored.environmentIOS, 'Sandbox'); + expect(restored.productId, 'premium_sub'); + expect(restored.willExpireSoon, isTrue); }); }); - group('Purchase Type Tests', () { - test('Purchase should be created with required fields', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'transaction_123', - transactionDate: 1234567890, - transactionReceipt: 'receipt_data', - purchaseToken: 'token_123', - platform: IapPlatform.android, + group('Purchase serialization', () { + test('PurchaseAndroid toJson/fromJson preserves token', () { + const purchase = PurchaseAndroid( + id: 'txn_android', + isAutoRenewing: true, + platform: IapPlatform.Android, + productId: 'monthly_access', + purchaseState: PurchaseState.Purchased, + purchaseToken: 'android_token', + quantity: 1, + transactionDate: 1700000000, ); - expect(purchase.productId, 'test_product'); - expect(purchase.transactionId, 'transaction_123'); - expect(purchase.id, 'transaction_123'); // id should return transactionId - expect(purchase.transactionDate, 1234567890); - expect(purchase.transactionReceipt, 'receipt_data'); - expect(purchase.purchaseToken, 'token_123'); - expect(purchase.platform, IapPlatform.android); - }); - - test('Purchase should handle Android-specific fields', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'transaction_123', - transactionDate: 1234567890, - transactionReceipt: 'receipt_data', - purchaseToken: 'token_123', - platform: IapPlatform.android, - isAcknowledgedAndroid: true, - purchaseStateAndroid: 1, - originalJson: '{"test": "data"}', - signatureAndroid: 'signature_123', - packageNameAndroid: 'com.example.app', - autoRenewingAndroid: false, - ); - - expect(purchase.isAcknowledgedAndroid, true); - expect(purchase.purchaseStateAndroid, 1); - expect(purchase.originalJson, '{"test": "data"}'); - expect(purchase.signatureAndroid, 'signature_123'); - expect(purchase.packageNameAndroid, 'com.example.app'); - expect(purchase.autoRenewingAndroid, false); - }); - - test('Purchase should handle iOS-specific fields', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'transaction_123', - transactionDate: 1234567890, - transactionReceipt: 'receipt_data', - purchaseToken: 'token_123', - platform: IapPlatform.ios, - transactionStateIOS: TransactionState.purchased, - originalTransactionIdentifierIOS: 'original_123', - originalTransactionDateIOS: '1234567890', - quantityIOS: 2, - ); - - expect(purchase.transactionStateIOS, TransactionState.purchased); - expect(purchase.originalTransactionIdentifierIOS, 'original_123'); - expect(purchase.originalTransactionDateIOS, '1234567890'); - expect(purchase.quantityIOS, 2); - }); - - test('Purchase should be created correctly', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'transaction_123', - transactionDate: 1234567890, - transactionReceipt: 'receipt_data', - purchaseToken: 'token_123', - platform: IapPlatform.android, - ); + final json = purchase.toJson(); + final restored = PurchaseAndroid.fromJson(json); - expect(purchase.productId, 'test_product'); - expect(purchase.transactionId, 'transaction_123'); - expect(purchase.transactionDate, 1234567890); - expect(purchase.transactionReceipt, 'receipt_data'); - expect(purchase.purchaseToken, 'token_123'); - expect(purchase.platform, IapPlatform.android); + expect(restored.purchaseToken, 'android_token'); + expect(restored.platform, IapPlatform.Android); + expect(restored.purchaseState, PurchaseState.Purchased); }); - test('Purchase fields should be accessible', () { - final purchase = Purchase( - productId: 'test_product', - transactionId: 'transaction_123', - transactionDate: 1234567890, - transactionReceipt: 'receipt_data', - purchaseToken: 'token_123', - platform: IapPlatform.ios, - isAcknowledged: true, - purchaseState: PurchaseState.purchased, + test('PurchaseIOS toJson/fromJson preserves StoreKit data', () { + final purchase = PurchaseIOS( + id: 'txn_ios', + isAutoRenewing: false, + platform: IapPlatform.IOS, + productId: 'premium_upgrade', + purchaseState: PurchaseState.Purchased, + quantity: 1, + transactionDate: 1700000300, + environmentIOS: 'Production', + subscriptionGroupIdIOS: 'group_a', ); - expect(purchase.productId, 'test_product'); - expect(purchase.transactionId, 'transaction_123'); - expect(purchase.transactionDate, 1234567890); - expect(purchase.transactionReceipt, 'receipt_data'); - expect(purchase.purchaseToken, 'token_123'); - expect(purchase.platform, IapPlatform.ios); - expect(purchase.isAcknowledged, true); - expect(purchase.purchaseState, PurchaseState.purchased); - }); - }); + final json = purchase.toJson(); + final restored = PurchaseIOS.fromJson(json); - group('PurchaseState Tests', () { - test('PurchaseState enum values should be correct', () { - expect(PurchaseState.pending.name, 'pending'); - expect(PurchaseState.purchased.name, 'purchased'); - expect(PurchaseState.unspecified.name, 'unspecified'); + expect(restored.environmentIOS, 'Production'); + expect(restored.subscriptionGroupIdIOS, 'group_a'); + expect(restored.platform, IapPlatform.IOS); }); }); - group('TransactionState Tests', () { - test('TransactionState enum values should be correct', () { - expect(TransactionState.purchasing.index, 0); - expect(TransactionState.purchased.index, 1); - expect(TransactionState.failed.index, 2); - expect(TransactionState.restored.index, 3); - expect(TransactionState.deferred.index, 4); - }); - }); - - group('AndroidPurchaseState Tests', () { - test('AndroidPurchaseState values should be correct', () { - expect(AndroidPurchaseState.unspecified.value, 0); - expect(AndroidPurchaseState.purchased.value, 1); - expect(AndroidPurchaseState.pending.value, 2); - }); - - test('AndroidPurchaseState fromValue should work correctly', () { - expect( - AndroidPurchaseState.fromValue(0), - AndroidPurchaseState.unspecified, + group('PurchaseError conversions', () { + test('toJson/fromJson retains code and message', () { + final error = PurchaseError( + code: ErrorCode.NetworkError, + message: 'Network unavailable', + productId: 'product_a', ); - expect(AndroidPurchaseState.fromValue(1), AndroidPurchaseState.purchased); - expect(AndroidPurchaseState.fromValue(2), AndroidPurchaseState.pending); - expect( - AndroidPurchaseState.fromValue(99), - AndroidPurchaseState.unspecified, - ); - }); - }); - - group('ValidationResult Tests', () { - test('ValidationResult should be created correctly', () { - final result = ValidationResult( - isValid: true, - errorMessage: null, - receipt: {'status': 0}, - parsedReceipt: {'product_id': 'test'}, - ); - - expect(result.isValid, true); - expect(result.errorMessage, isNull); - expect(result.receipt, isNotNull); - expect(result.parsedReceipt, isNotNull); - }); - - test('ValidationResult fromJson should deserialize correctly', () { - final json = { - 'isValid': false, - 'errorMessage': 'Invalid receipt', - 'receipt': {'status': 21007}, - }; - final result = ValidationResult.fromJson(json); + final json = error.toJson(); + final restored = PurchaseError.fromJson(json); - expect(result.isValid, false); - expect(result.errorMessage, 'Invalid receipt'); - expect(result.receipt?['status'], 21007); + expect(restored.code, ErrorCode.NetworkError); + expect(restored.message, 'Network unavailable'); + expect(restored.productId, 'product_a'); }); }); } diff --git a/test/subscription_flow_test.dart b/test/subscription_flow_test.dart deleted file mode 100644 index 29b43e703..000000000 --- a/test/subscription_flow_test.dart +++ /dev/null @@ -1,549 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:platform/platform.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('Subscription Flow Tests', () { - late FlutterInappPurchase plugin; - final List methodChannelLog = []; - - setUp(() { - methodChannelLog.clear(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - methodChannelLog.add(methodCall); - switch (methodCall.method) { - case 'initConnection': - return true; - case 'endConnection': - return true; - case 'fetchProducts': - final args = methodCall.arguments; - final productIds = args is Map - ? (args['skus'] as Iterable?)?.map((e) => e.toString()).toList() - : args is Iterable - ? args.map((e) => e.toString()).toList() - : null; - final allSubs = _getMockSubscriptions(); - final filteredSubs = productIds != null - ? allSubs - .where( - (sub) => productIds.contains(sub['productId']), - ) - .toList() - : allSubs; - return filteredSubs - .map((item) => Map.from(item)) - .toList(); - case 'requestSubscription': - return _getMockSubscriptionPurchase(methodCall.arguments); - case 'buyItemByType': - return _getMockSubscriptionPurchase(methodCall.arguments); - case 'requestPurchase': - return _getMockSubscriptionPurchase(methodCall.arguments); - case 'requestProductWithOfferIOS': - return _getMockSubscriptionPurchase(methodCall.arguments); - case 'getActiveSubscriptions': - // For Android, it uses getAvailableItemsByType with type 'subs' - return _getMockActiveSubscriptions( - methodCall.arguments, - ).map((item) => Map.from(item)).toList(); - case 'getAvailablePurchases': - return _getMockActiveSubscriptions( - methodCall.arguments, - ).map((item) => Map.from(item)).toList(); - case 'restorePurchases': - return _getMockActiveSubscriptions( - methodCall.arguments, - ).map((item) => Map.from(item)).toList(); - case 'hasActiveSubscriptions': - return _getHasActiveSubscriptions(methodCall.arguments); - case 'acknowledgePurchaseAndroid': - return {'acknowledged': true}; - case 'finishTransaction': - return 'finished'; - case 'getAvailableItems': - // Return active subscriptions only for 'subs' type - // For 'inapp' type, return empty (no regular purchases in this test) - return _getMockActiveSubscriptions( - null, - ).map((item) => Map.from(item)).toList(); - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), null); - }); - - group('Get Subscriptions', () { - test('getSubscriptions returns subscription products', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final subscriptions = await plugin.fetchProducts( - skus: ['monthly_sub', 'yearly_sub'], - type: ProductType.subs, - ); - - expect(subscriptions.length, 2); - expect(subscriptions[0].id, 'monthly_sub'); - expect(subscriptions[0].displayPrice, '\$9.99'); - expect(subscriptions[1].id, 'yearly_sub'); - expect(subscriptions[1].displayPrice, '\$99.99'); - }); - - test('getSubscriptions on iOS includes iOS-specific fields', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final subscriptions = await plugin.fetchProducts( - skus: ['ios_monthly_sub'], - type: ProductType.subs, - ); - - expect(subscriptions.length, 1); - final sub = subscriptions[0] as ProductSubscription; - expect(sub.id, 'ios_monthly_sub'); - // These fields are not parsed from the mock data, so checking for non-null is enough - expect(sub.title, 'iOS Monthly'); - expect(sub.displayPrice, '\$9.99'); - }); - - test('getSubscriptions on Android includes offer details', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final subscriptions = await plugin.fetchProducts( - skus: ['android_sub_with_offers'], - type: ProductType.subs, - ); - - expect(subscriptions.length, 1); - final sub = subscriptions[0] as ProductSubscription; - expect(sub.id, 'android_sub_with_offers'); - // Offers are parsed differently, just check the subscription exists - expect(sub.displayPrice, '\$19.99'); - expect(sub.title, 'Premium Subscription'); - }); - }); - - group('Active Subscriptions', () { - test('getActiveSubscriptions returns active subs on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final activeSubs = await plugin.getActiveSubscriptions(); - - // The mock returns 3 items total (2 android + 1 ios) - expect(activeSubs.length, greaterThan(0)); - // Just verify we have some active subscriptions - final androidSubs = activeSubs - .where((s) => s.productId.startsWith('active_sub_')) - .toList(); - expect(androidSubs.isNotEmpty, true); - }); - - test('getActiveSubscriptions filters by subscription IDs', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.getActiveSubscriptions(subscriptionIds: ['active_sub_1']); - - // No channel type check; filtering happens in Dart layer - }); - - test('getActiveSubscriptions on iOS includes iOS fields', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final activeSubs = await plugin.getActiveSubscriptions(); - - // The mock returns multiple items - expect(activeSubs.length, greaterThan(0)); - // Just verify we have some subscriptions - final iosSub = activeSubs.firstWhere( - (s) => s.productId == 'ios_active_sub', - orElse: () => activeSubs[0], - ); - expect(iosSub.productId, isNotEmpty); - }); - - test('hasActiveSubscriptions returns true when has active', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final hasActive = await plugin.hasActiveSubscriptions(); - - // hasActiveSubscriptions returns false if no active subs found - // Since our mock returns empty arrays, it will be false - expect(hasActive, anyOf(true, false)); - }); - - test('hasActiveSubscriptions filters by subscription IDs', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.hasActiveSubscriptions(subscriptionIds: ['specific_sub']); - - // hasActiveSubscriptions uses type 'subs' for checking active subscriptions - expect(methodChannelLog.last.arguments['type'], 'subs'); - }); - }); - - group('Subscription Purchase', () { - test('requestSubscription on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestPurchaseAndroid(skus: ['monthly_sub']), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - expect(methodChannelLog.last.arguments['productId'], 'monthly_sub'); - }); - - test('requestSubscription with offer token on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestSubscriptionAndroid( - skus: ['yearly_sub'], - subscriptionOffers: [ - SubscriptionOfferAndroid( - sku: 'yearly_sub', - offerToken: 'special_offer_token', - ), - ], - ), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - expect(methodChannelLog.last.arguments['productId'], 'yearly_sub'); - // Offer tokens are passed in subscriptionOffers - // Check that we passed the subscription arguments - expect(methodChannelLog.last.arguments['productId'], 'yearly_sub'); - }); - - test('requestSubscription with replacement on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - android: RequestSubscriptionAndroid( - skus: ['upgraded_sub'], - purchaseTokenAndroid: 'old_purchase_token', - replacementModeAndroid: 1, // immediateWithTimeProration - subscriptionOffers: [], - ), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - // Check that purchaseToken is passed (it's passed as 'purchaseToken' not 'oldPurchaseToken') - expect( - methodChannelLog.last.arguments['purchaseToken'], - 'old_purchase_token', - ); - expect(methodChannelLog.last.arguments['replacementMode'], 1); - }); - - test('requestSubscription on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - ios: RequestPurchaseIOS(sku: 'ios_monthly_sub'), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestPurchase'); - expect(methodChannelLog.last.arguments['sku'], 'ios_monthly_sub'); - }); - - test('requestSubscription with offer on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - await plugin.requestPurchase( - request: RequestPurchase( - ios: RequestPurchaseIOS( - sku: 'ios_yearly_sub', - withOffer: PaymentDiscount( - identifier: 'promo_offer', - keyIdentifier: 'key_123', - nonce: 'nonce_456', - signature: 'signature_789', - timestamp: '1234567890', - ), - ), - ), - type: ProductType.subs, - ); - - expect(methodChannelLog.last.method, 'requestProductWithOfferIOS'); - expect(methodChannelLog.last.arguments['sku'], 'ios_yearly_sub'); - expect( - methodChannelLog.last.arguments['withOffer']['identifier'], - 'promo_offer', - ); - }); - }); - - group('Subscription Management', () { - test('finishTransaction for subscription on Android', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'monthly_sub', - transactionId: 'GPA.SUB-123', - purchaseToken: 'sub_token_123', - platform: IapPlatform.android, - isAcknowledgedAndroid: false, - autoRenewingAndroid: true, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'acknowledgePurchase'); - expect( - methodChannelLog.last.arguments['purchaseToken'], - 'sub_token_123', - ); - }); - - test('finishTransaction for subscription on iOS', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await plugin.initConnection(); - - final purchase = Purchase( - productId: 'ios_monthly_sub', - transactionId: '2000000456789', - platform: IapPlatform.ios, - ); - - await plugin.finishTransaction(purchase); - - expect(methodChannelLog.last.method, 'finishTransaction'); - expect( - methodChannelLog.last.arguments['transactionId'], - '2000000456789', - ); - }); - }); - - group('Subscription Status', () { - test('checks subscription expiration', () async { - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final activeSubs = await plugin.getActiveSubscriptions(); - - // The mock may return empty or have subscriptions - if (activeSubs.isNotEmpty) { - final sub = activeSubs[0]; - // Check if subscription has expected fields - expect(sub.productId, isNotEmpty); - } else { - // If no active subs, that's also valid - expect(activeSubs.isEmpty, true); - } - }); - - test('handles expired subscriptions', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('flutter_inapp'), ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'initConnection') return true; - if (methodCall.method == 'getActiveSubscriptions') { - return >[]; // No active subscriptions - } - - if (methodCall.method == 'getAvailableItems') { - return >[]; // No available items - } - return null; - }); - - plugin = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'android'), - ); - await plugin.initConnection(); - - final activeSubs = await plugin.getActiveSubscriptions(); - expect(activeSubs.isEmpty, true); - - final hasActive = await plugin.hasActiveSubscriptions(); - expect(hasActive, false); - }); - }); - }); -} - -List> _getMockSubscriptions() { - return >[ - { - 'productId': 'monthly_sub', - 'price': '\$9.99', - 'currency': 'USD', - 'localizedPrice': '\$9.99', - 'title': 'Monthly Subscription', - 'description': 'Access all features for a month', - 'subscriptionPeriodAndroid': 'P1M', - 'subscriptionGroupIdentifierIOS': 'group_123', - 'subscriptionPeriodUnitIOS': 'MONTH', - 'subscriptionPeriodNumberIOS': '1', - }, - { - 'productId': 'yearly_sub', - 'price': '\$99.99', - 'currency': 'USD', - 'localizedPrice': '\$99.99', - 'title': 'Yearly Subscription', - 'description': 'Access all features for a year', - 'subscriptionPeriodAndroid': 'P1Y', - }, - { - 'productId': 'ios_monthly_sub', - 'price': '\$9.99', - 'currency': 'USD', - 'localizedPrice': '\$9.99', - 'title': 'iOS Monthly', - 'subscriptionGroupIdentifierIOS': 'group_123', - 'subscriptionPeriodUnitIOS': 'MONTH', - 'subscriptionPeriodNumberIOS': '1', - }, - { - 'productId': 'android_sub_with_offers', - 'price': '\$19.99', - 'currency': 'USD', - 'localizedPrice': '\$19.99', - 'title': 'Premium Subscription', - 'displayPrice': '\$19.99', - 'subscriptionOfferDetailsAndroid': >[ - { - 'basePlanId': 'monthly', - 'offerId': 'offer1', - 'offerToken': 'offer_token_1', - 'pricingPhases': { - 'pricingPhaseList': [ - { - 'formattedPrice': '\$19.99', - 'priceCurrencyCode': 'USD', - 'priceAmountMicros': 19990000, - 'billingPeriod': 'P1M', - 'recurrenceMode': 1, - }, - ], - }, - }, - ], - }, - ]; -} - -Map _getMockSubscriptionPurchase(dynamic arguments) { - final args = arguments as Map?; - return { - 'productId': args?['productId'] ?? args?['sku'] ?? 'monthly_sub', - 'purchaseToken': 'mock_sub_token', - 'orderId': 'SUB_ORDER_123', - 'transactionId': 'GPA.SUB-123', - 'purchaseState': 1, - 'isAcknowledged': false, - 'autoRenewing': true, - }; -} - -List> _getMockActiveSubscriptions(dynamic arguments) { - final List> allSubs = >[ - { - 'productId': 'active_sub_1', - 'purchaseToken': 'active_token_1', - 'transactionId': 'GPA.ACTIVE-001', - 'autoRenewingAndroid': true, - 'purchaseStateAndroid': 1, // 1 = PURCHASED - 'isAcknowledgedAndroid': true, - }, - { - 'productId': 'active_sub_2', - 'purchaseToken': 'active_token_2', - 'transactionId': 'GPA.ACTIVE-002', - 'autoRenewingAndroid': false, - 'purchaseStateAndroid': 1, // 1 = PURCHASED - 'isAcknowledgedAndroid': true, - }, - { - 'productId': 'ios_active_sub', - 'transactionId': '3000000123456', - 'transactionStateIOS': 'purchased', - }, - ]; - - if (arguments != null && arguments is List) { - return allSubs - .where((sub) => arguments.contains(sub['productId'])) - .toList(); - } - - return allSubs; -} - -bool _getHasActiveSubscriptions(dynamic arguments) { - final activeSubs = _getMockActiveSubscriptions(arguments); - return activeSubs.isNotEmpty; -} diff --git a/test/types_additions_test.dart b/test/types_additions_test.dart index cb5ee2f87..c2d2647fb 100644 --- a/test/types_additions_test.dart +++ b/test/types_additions_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer_const_constructors + import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,48 +7,55 @@ void main() { group('types additions', () { test('AppTransaction json roundtrip', () { final tx = AppTransaction( - appAppleId: '123', + appId: 123, + appVersion: '1.0.0', + appVersionId: 456, bundleId: 'com.example', - originalAppVersion: '1.0', - originalPurchaseDate: '2024-01-01', - deviceVerification: 'ver', + deviceVerification: 'signature', deviceVerificationNonce: 'nonce', + environment: 'Sandbox', + originalAppVersion: '1.0.0', + originalPurchaseDate: 1700000000, + signedDate: 1700000020, ); final map = tx.toJson(); final back = AppTransaction.fromJson(map); expect(back.bundleId, 'com.example'); - expect(back.originalAppVersion, '1.0'); + expect(back.environment, 'Sandbox'); + expect(back.appVersion, '1.0.0'); }); - test('ActiveSubscription.fromPurchase sets iOS fields', () { - final now = DateTime.now(); - final p = Purchase( + test('ActiveSubscription toJson includes optional fields', () { + final sub = ActiveSubscription( + isActive: true, productId: 'sub', + purchaseToken: 'token', + transactionDate: 1700000100, transactionId: 't1', - platform: IapPlatform.ios, - expirationDateIOS: now.add(const Duration(days: 3)), environmentIOS: 'Sandbox', + expirationDateIOS: 1700001000, + willExpireSoon: true, ); - final active = ActiveSubscription.fromPurchase(p); - expect(active.productId, 'sub'); - expect(active.isActive, true); - expect(active.environmentIOS, 'Sandbox'); - expect(active.willExpireSoon, true); - expect(active.daysUntilExpirationIOS, isNonNegative); - final json = active.toJson(); + final json = sub.toJson(); expect(json['productId'], 'sub'); + expect(json['environmentIOS'], 'Sandbox'); + expect(json['willExpireSoon'], true); }); - test('PurchaseIOS holds expirationDateIOS', () { - final exp = DateTime.now().add(const Duration(days: 10)); + test('PurchaseIOS holds expirationDateIOS seconds', () { final p = PurchaseIOS( + id: 't', productId: 'p', - transactionId: 't', - expirationDateIOS: exp, + isAutoRenewing: false, + platform: IapPlatform.IOS, + purchaseState: PurchaseState.Purchased, + quantity: 1, + transactionDate: 1700000000, + expirationDateIOS: 1700005000, ); - expect(p.expirationDateIOS, exp); - expect(p.platform, IapPlatform.ios); + expect(p.expirationDateIOS, 1700005000); + expect(p.platform, IapPlatform.IOS); }); }); } diff --git a/test/types_platform_test.dart b/test/types_platform_test.dart index 13bd6fe85..20446d05f 100644 --- a/test/types_platform_test.dart +++ b/test/types_platform_test.dart @@ -2,357 +2,114 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_inapp_purchase/types.dart'; void main() { - group('Platform-specific Types Coverage', () { - group('Product toJson platform checks', () { - test('should include Android fields only on Android platform', () { - final product = Product( - id: 'android_product', - priceString: '9.99', - currency: 'USD', - platformEnum: IapPlatform.android, - nameAndroid: 'Android Product Name', - oneTimePurchaseOfferDetailsAndroid: { - 'priceAmountMicros': 9990000, - 'priceCurrencyCode': 'USD', - 'formattedPrice': '\$9.99', - }, - ); - - final json = product.toJson(); - - // Should include Android fields - expect(json['platform'], 'android'); - expect(json['nameAndroid'], 'Android Product Name'); - expect(json['oneTimePurchaseOfferDetailsAndroid'], isNotNull); - - // Should NOT include iOS fields - expect(json.containsKey('environmentIOS'), false); - expect(json.containsKey('subscriptionGroupIdIOS'), false); - }); - - test('should include iOS fields only on iOS platform', () { - final product = Product( - id: 'ios_product', - priceString: '9.99', - currency: 'USD', - platformEnum: IapPlatform.ios, - environmentIOS: 'Sandbox', - subscriptionGroupIdIOS: 'group_123', - promotionalOfferIdsIOS: ['promo1', 'promo2'], - ); - - final json = product.toJson(); - - // Should include iOS fields - expect(json['platform'], 'ios'); - expect(json['environmentIOS'], 'Sandbox'); - expect(json['subscriptionGroupIdIOS'], 'group_123'); - expect(json['promotionalOfferIdsIOS'], ['promo1', 'promo2']); - - // Should NOT include Android fields - expect(json.containsKey('nameAndroid'), false); - expect(json.containsKey('oneTimePurchaseOfferDetailsAndroid'), false); - }); - }); - - group('Subscription platform checks', () { - test('should handle subscription offer details on Android platform', () { - final subscription = ProductSubscription( - id: 'android_sub', - price: '9.99', - currency: 'USD', - platform: IapPlatform.android, - subscriptionOfferDetailsAndroid: [ - OfferDetail( - offerId: 'offer_123', - basePlanId: 'base_plan_monthly', - pricingPhases: [], - ), - ], - ); - - final json = subscription.toJson(); - expect(json['platform'], 'android'); - expect(json['subscriptionOfferDetailsAndroid'], isNotNull); - }); - - test('should handle iOS subscription fields', () { - final subscription = ProductSubscription( - id: 'ios_sub', - price: '9.99', - currency: 'USD', - platform: IapPlatform.ios, - environmentIOS: 'Production', - subscriptionGroupIdIOS: 'ios_group_456', - ); - - final json = subscription.toJson(); - expect(json['platform'], 'ios'); - expect(json['environmentIOS'], 'Production'); - expect(json['subscriptionGroupIdIOS'], 'ios_group_456'); - }); + group('Platform-specific product serialization', () { + test('ProductAndroid toJson includes Android metadata', () { + const product = ProductAndroid( + currency: 'USD', + description: 'Android pass', + displayPrice: '\$9.99', + id: 'android_pass', + nameAndroid: 'Android Pass', + platform: IapPlatform.Android, + price: 9.99, + title: 'Android Pass', + type: ProductType.InApp, + ); + + final json = product.toJson(); + + expect(json['platform'], IapPlatform.Android.toJson()); + expect(json['nameAndroid'], 'Android Pass'); + expect(json.containsKey('displayNameIOS'), isFalse); }); - group('OfferDetail and PricingPhase', () { - test('should create OfferDetail with required fields', () { - final offer = OfferDetail( - offerId: 'test_offer', - basePlanId: 'monthly_base', - pricingPhases: [ - PricingPhase( - priceAmount: 9.99, - price: '9.99', - currency: 'USD', - billingPeriod: 'P1M', - ), - ], - ); - - expect(offer.offerId, 'test_offer'); - expect(offer.basePlanId, 'monthly_base'); - expect(offer.pricingPhases.length, 1); - expect(offer.pricingPhases.first.priceAmount, 9.99); - }); - - test('should convert OfferDetail to JSON', () { - final offer = OfferDetail( - offerId: 'json_offer', - basePlanId: 'base_monthly', - pricingPhases: [ - PricingPhase( - priceAmount: 5.99, - price: '5.99', - currency: 'EUR', - billingPeriod: 'P1M', - billingCycleCount: 1, - ), - ], - ); - - final json = offer.toJson(); - expect(json['offerId'], 'json_offer'); - expect(json['basePlanId'], 'base_monthly'); - expect(json['pricingPhases'], isA>()); - expect(json['pricingPhases']['pricingPhaseList'], isA>()); - expect((json['pricingPhases']['pricingPhaseList'] as List).length, 1); - }); - - test('should create PricingPhase with all fields', () { - final phase = PricingPhase( - priceAmount: 7.99, - price: '7.99', - currency: 'GBP', - billingPeriod: 'P3M', - billingCycleCount: 4, - recurrenceMode: RecurrenceMode.finiteRecurring, - ); - - expect(phase.priceAmount, 7.99); - expect(phase.price, '7.99'); - expect(phase.currency, 'GBP'); - expect(phase.billingPeriod, 'P3M'); - expect(phase.billingCycleCount, 4); - expect(phase.recurrenceMode, RecurrenceMode.finiteRecurring); - }); - - test('should parse OfferDetail from JSON with nested structure', () { - final json = { - 'offerId': 'parsed_offer', - 'basePlanId': 'parsed_base', - 'pricingPhases': [ - { - 'priceAmount': 12.99, - 'price': '12.99', - 'currency': 'CAD', - 'billingPeriod': 'P1Y', - 'billingCycleCount': 1, - }, - ], - }; - - final offer = OfferDetail.fromJson(json); - expect(offer.offerId, 'parsed_offer'); - expect(offer.basePlanId, 'parsed_base'); - expect(offer.pricingPhases.length, 1); - expect(offer.pricingPhases.first.priceAmount, 12.99); - expect(offer.pricingPhases.first.currency, 'CAD'); - }); + test('ProductIOS toJson includes StoreKit metadata', () { + const product = ProductIOS( + currency: 'USD', + description: 'Premium subscription', + displayNameIOS: 'Premium', + displayPrice: '\$7.99', + id: 'premium_ios', + isFamilyShareableIOS: true, + jsonRepresentationIOS: '{}', + platform: IapPlatform.IOS, + price: 7.99, + title: 'Premium', + type: ProductType.Subs, + typeIOS: ProductTypeIOS.AutoRenewableSubscription, + ); + + final json = product.toJson(); + + expect(json['platform'], IapPlatform.IOS.toJson()); + expect(json['displayNameIOS'], 'Premium'); + expect(json.containsKey('nameAndroid'), isFalse); }); + }); - group('Purchase platform checks', () { - test('Purchase toString should show Android fields only on Android', () { - final purchase = Purchase( - productId: 'android_purchase', - platform: IapPlatform.android, - transactionId: 'GPA.123456789', - purchaseToken: 'android_token', - dataAndroid: '{"test": "android_data"}', - signatureAndroid: 'android_signature', - purchaseStateAndroid: 1, - isAcknowledgedAndroid: true, - autoRenewingAndroid: false, - packageNameAndroid: 'com.example.app', - orderIdAndroid: 'GPA.ORDER.123', - obfuscatedAccountIdAndroid: 'account_123', - obfuscatedProfileIdAndroid: 'profile_456', - ); - - final result = purchase.toString(); - - // Should include Android-specific fields - expect(result, contains('android')); - expect(result, contains('dataAndroid')); - expect(result, contains('signatureAndroid')); - expect(result, contains('purchaseStateAndroid')); - expect(result, contains('isAcknowledgedAndroid')); - expect(result, contains('autoRenewingAndroid')); - expect(result, contains('packageNameAndroid')); - expect(result, contains('orderIdAndroid')); - expect(result, contains('obfuscatedAccountIdAndroid')); - expect(result, contains('obfuscatedProfileIdAndroid')); - - // Should NOT include iOS-specific fields in output - expect(result, isNot(contains('environmentIOS'))); - expect(result, isNot(contains('transactionStateIOS'))); - }); - - test('Purchase toString should show iOS fields only on iOS', () { - final purchase = Purchase( - productId: 'ios_purchase', - platform: IapPlatform.ios, - transactionId: '2000000123456789', - purchaseToken: 'ios_jws_token', - originalTransactionDateIOS: '2023-08-20T10:00:00Z', - originalTransactionIdentifierIOS: '2000000000000001', - isUpgradeIOS: false, - transactionStateIOS: TransactionState.purchased, - environmentIOS: 'Production', - expirationDateIOS: DateTime.now().add(const Duration(days: 30)), - subscriptionGroupIdIOS: 'ios_group_789', - isUpgradedIOS: false, - offerCodeRefNameIOS: 'SUMMER2023', - offerIdentifierIOS: 'summer_discount', - storeFrontIOS: 'USA', - storeFrontCountryCodeIOS: 'US', - currencyCodeIOS: 'USD', - priceIOS: 9.99, - quantityIOS: 1, - appBundleIdIOS: 'com.example.ios.app', - productTypeIOS: 'Auto-Renewable Subscription', - ownershipTypeIOS: 'PURCHASED', - transactionReasonIOS: 'PURCHASE', - reasonIOS: 'PURCHASE', - webOrderLineItemIdIOS: 'WEB.ORDER.123', - ); - - final result = purchase.toString(); - - // Should include iOS-specific fields - expect(result, contains('ios')); - expect(result, contains('originalTransactionDateIOS')); - expect(result, contains('originalTransactionIdentifierIOS')); - expect(result, contains('transactionStateIOS')); - expect(result, contains('environmentIOS')); - expect(result, contains('subscriptionGroupIdIOS')); - expect(result, contains('quantityIOS')); - expect(result, contains('appBundleIdIOS')); - expect(result, contains('productTypeIOS')); - expect(result, contains('ownershipTypeIOS')); - expect(result, contains('transactionReasonIOS')); - expect(result, contains('webOrderLineItemIdIOS')); - expect(result, contains('storefrontCountryCodeIOS')); - - // Should NOT include Android-specific fields in output - expect(result, isNot(contains('dataAndroid'))); - expect(result, isNot(contains('signatureAndroid'))); - }); - - test('Purchase id and ids getters should work correctly', () { - final purchase = Purchase( - productId: 'test_product', - platform: IapPlatform.android, - transactionId: 'test_transaction_123', - ); - - // OpenIAP compliant getters - expect(purchase.id, 'test_transaction_123'); - expect(purchase.ids, ['test_product']); - }); - - test('Purchase with empty transactionId should return empty id', () { - final purchase = Purchase( - productId: 'test_product', - platform: IapPlatform.android, - transactionId: null, - ); - - expect(purchase.id, ''); - expect(purchase.ids, ['test_product']); - }); + group('Subscription offer details', () { + test('Android subscription contains pricing phases', () { + const phases = PricingPhasesAndroid( + pricingPhaseList: [ + PricingPhaseAndroid( + billingCycleCount: 3, + billingPeriod: 'P1M', + formattedPrice: '\$0.99', + priceAmountMicros: '990000', + priceCurrencyCode: 'USD', + recurrenceMode: 1, + ), + ], + ); + + const offer = ProductSubscriptionAndroidOfferDetails( + basePlanId: 'base_plan', + offerId: 'intro_offer', + offerTags: ['intro'], + offerToken: 'token_123', + pricingPhases: phases, + ); + + const subscription = ProductSubscriptionAndroid( + currency: 'USD', + description: 'Android subscription', + displayPrice: '\$4.99', + id: 'android_sub', + nameAndroid: 'Android Subscription', + platform: IapPlatform.Android, + price: 4.99, + subscriptionOfferDetailsAndroid: [offer], + title: 'Android Subscription', + type: ProductType.Subs, + ); + + expect(subscription.subscriptionOfferDetailsAndroid.length, 1); + expect( + subscription.subscriptionOfferDetailsAndroid.first.pricingPhases + .pricingPhaseList.first.formattedPrice, + '\$0.99', + ); }); - group('DiscountIOS tests', () { - test('should create DiscountIOS with all fields', () { - final discount = DiscountIOS( - identifier: 'intro_offer', - type: 'introductory', - numberOfPeriods: '3', - price: '1.99', - localizedPrice: '\$1.99', - paymentMode: 'payUpFront', - subscriptionPeriod: 'P1M', - ); - - expect(discount.identifier, 'intro_offer'); - expect(discount.type, 'introductory'); - expect(discount.numberOfPeriods, '3'); - expect(discount.price, '1.99'); - expect(discount.localizedPrice, '\$1.99'); - expect(discount.paymentMode, 'payUpFront'); - expect(discount.subscriptionPeriod, 'P1M'); - }); - - test('should convert DiscountIOS to JSON', () { - final discount = DiscountIOS( - identifier: 'promo_offer', - type: 'promotional', - numberOfPeriods: '1', - price: '0.99', - localizedPrice: '\$0.99', - paymentMode: 'payAsYouGo', - subscriptionPeriod: 'P1W', - ); - - final json = discount.toJson(); - expect(json['identifier'], 'promo_offer'); - expect(json['type'], 'promotional'); - expect(json['numberOfPeriods'], '1'); - expect(json['price'], '0.99'); - expect(json['localizedPrice'], '\$0.99'); - expect(json['paymentMode'], 'payAsYouGo'); - expect(json['subscriptionPeriod'], 'P1W'); - }); - - test('should parse DiscountIOS from JSON', () { - final json = { - 'identifier': 'parsed_discount', - 'type': 'parsed_type', - 'numberOfPeriods': '2', - 'price': '2.99', - 'localizedPrice': '\$2.99', - 'paymentMode': 'payUpFront', - 'subscriptionPeriod': 'P2W', - }; - - final discount = DiscountIOS.fromJson(json); - expect(discount.identifier, 'parsed_discount'); - expect(discount.type, 'parsed_type'); - expect(discount.numberOfPeriods, '2'); - expect(discount.price, '2.99'); - expect(discount.localizedPrice, '\$2.99'); - expect(discount.paymentMode, 'payUpFront'); - expect(discount.subscriptionPeriod, 'P2W'); - }); + test('iOS subscription encodes introductory pricing metadata', () { + const subscription = ProductSubscriptionIOS( + currency: 'USD', + description: 'iOS subscription', + displayNameIOS: 'iOS Premium', + displayPrice: '\$5.99', + id: 'ios_sub', + isFamilyShareableIOS: false, + jsonRepresentationIOS: '{}', + platform: IapPlatform.IOS, + title: 'iOS Premium', + type: ProductType.Subs, + typeIOS: ProductTypeIOS.AutoRenewableSubscription, + ); + + final json = subscription.toJson(); + expect(json['platform'], IapPlatform.IOS.toJson()); + expect(json['displayNameIOS'], 'iOS Premium'); + expect(json.containsKey('subscriptionOfferDetailsAndroid'), isFalse); }); }); } diff --git a/test/utils_android_response_test.dart b/test/utils_android_response_test.dart deleted file mode 100644 index 1b3688367..000000000 --- a/test/utils_android_response_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_inapp_purchase/utils.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('parseAndLogAndroidResponse handles valid/invalid JSON', () { - // Valid JSON string - parseAndLogAndroidResponse( - '{"responseCode":0,"debugMessage":"ok"}', - successLog: 'success', - failureLog: 'fail', - ); - - // Invalid JSON string should not throw - parseAndLogAndroidResponse( - '{invalid', - successLog: 'success', - failureLog: 'fail', - ); - - // Null / non-string are ignored - parseAndLogAndroidResponse( - null, - successLog: 'success', - failureLog: 'fail', - ); - parseAndLogAndroidResponse( - 42, - successLog: 'success', - failureLog: 'fail', - ); - }); -} diff --git a/test/utils_test.dart b/test/utils_test.dart deleted file mode 100644 index a452ee42d..000000000 --- a/test/utils_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -// Ignore naming lint in test enum for explicit name value expectations -// ignore_for_file: constant_identifier_names -enum _TestEnum { Hoge } - -void main() { - group('utils', () { - test('EnumUtil.getValueString', () async { - String value = _TestEnum.Hoge.name; - expect(value, 'Hoge'); - }); - }); -} diff --git a/tool/lints/core.yaml b/tool/lints/core.yaml new file mode 100644 index 000000000..8609c1960 --- /dev/null +++ b/tool/lints/core.yaml @@ -0,0 +1,37 @@ +# Copied from flutter_lints 3.0.2 (lib/core.yaml) +# See https://github.com/dart-lang/lints for updates. + +linter: + rules: + - avoid_empty_else + - avoid_relative_lib_imports + - avoid_shadowing_type_parameters + - avoid_types_as_parameter_names + - await_only_futures + - camel_case_extensions + - camel_case_types + - collection_methods_unrelated_type + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - empty_catches + - file_names + - hash_and_equals + - implicit_call_tearoffs + - no_duplicate_case_values + - non_constant_identifier_names + - null_check_on_nullable_type_parameter + - package_prefixed_library_names + - prefer_generic_function_type_aliases + - prefer_is_empty + - prefer_is_not_empty + - prefer_iterable_whereType + - prefer_typing_uninitialized_variables + - provide_deprecation_message + - secure_pubspec_urls + - type_literal_in_constant_pattern + - unnecessary_overrides + - unrelated_type_equality_checks + - use_string_in_part_of_directives + - valid_regexps + - void_checks diff --git a/tool/lints/flutter.yaml b/tool/lints/flutter.yaml new file mode 100644 index 000000000..e2bc634d1 --- /dev/null +++ b/tool/lints/flutter.yaml @@ -0,0 +1,20 @@ +# Copied from flutter_lints 3.0.2 (lib/flutter.yaml) +# Depends on recommended.yaml in the same directory. + +include: recommended.yaml + +linter: + rules: + - avoid_print + - avoid_unnecessary_containers + - avoid_web_libraries_in_flutter + - no_logic_in_create_state + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - sized_box_for_whitespace + - sort_child_properties_last + - use_build_context_synchronously + - use_full_hex_values_for_flutter_colors + - use_key_in_widget_constructors diff --git a/tool/lints/recommended.yaml b/tool/lints/recommended.yaml new file mode 100644 index 000000000..343e51dba --- /dev/null +++ b/tool/lints/recommended.yaml @@ -0,0 +1,62 @@ +# Copied from flutter_lints 3.0.2 (lib/recommended.yaml) +# Depends on core.yaml in the same directory. + +include: core.yaml + +linter: + rules: + - annotate_overrides + - avoid_function_literals_in_foreach_calls + - avoid_init_to_null + - avoid_null_checks_in_equality_operators + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_single_cascade_in_expression_statements + - constant_identifier_names + - control_flow_in_finally + - empty_constructor_bodies + - empty_statements + - exhaustive_cases + - implementation_imports + - library_names + - library_prefixes + - library_private_types_in_public_api + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - null_closures + - overridden_fields + - package_names + - prefer_adjacent_string_concatenation + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_contains + - prefer_final_fields + - prefer_for_elements_to_map_fromIterable + - prefer_function_declarations_over_variables + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_interpolation_to_compose_strings + - prefer_is_not_operator + - prefer_null_aware_operators + - prefer_spread_collections + - recursive_getters + - slash_for_doc_comments + - type_init_formals + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_getters_setters + - unnecessary_late + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - use_function_type_syntax_for_parameters + - use_rethrow_when_possible + - use_super_parameters