From ffd3b2d254c19cb3ff9de027d5fbcc78e4329aa4 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:53:54 -0700 Subject: [PATCH 1/3] feat: add revenuecat plugin --- README.md | 1 + .../revenuecat/NOTICE.revenuecat-ai-toolkit | 24 ++ plugins/revenuecat/README.md | 29 ++ plugins/revenuecat/index.ts | 34 +++ plugins/revenuecat/package.json | 21 ++ .../skills/create-revenuecat-project/SKILL.md | 89 ++++++ .../skills/integrate-revenuecat/SKILL.md | 127 +++++++++ .../integrate-revenuecat/platforms/android.md | 80 ++++++ .../integrate-revenuecat/platforms/flutter.md | 80 ++++++ .../integrate-revenuecat/platforms/ios.md | 100 +++++++ .../integrate-revenuecat/platforms/kmp.md | 93 +++++++ .../platforms/react-native.md | 62 +++++ .../skills/revenuecat-charts/SKILL.md | 186 +++++++++++++ .../revenuecat-customer-center/SKILL.md | 54 ++++ .../platforms/android.md | 89 ++++++ .../platforms/flutter.md | 107 ++++++++ .../platforms/ios.md | 134 +++++++++ .../platforms/kmp.md | 67 +++++ .../platforms/react-native.md | 101 +++++++ .../revenuecat-entitlements-gate/SKILL.md | 48 ++++ .../platforms/android.md | 91 +++++++ .../platforms/flutter.md | 86 ++++++ .../platforms/ios.md | 84 ++++++ .../platforms/kmp.md | 95 +++++++ .../platforms/react-native.md | 74 +++++ .../skills/revenuecat-identify-user/SKILL.md | 51 ++++ .../platforms/android.md | 100 +++++++ .../platforms/flutter.md | 76 ++++++ .../revenuecat-identify-user/platforms/ios.md | 79 ++++++ .../revenuecat-identify-user/platforms/kmp.md | 98 +++++++ .../platforms/react-native.md | 88 ++++++ .../skills/revenuecat-migrate/SKILL.md | 100 +++++++ .../revenuecat-migrate/platforms/android.md | 80 ++++++ .../revenuecat-migrate/platforms/flutter.md | 78 ++++++ .../revenuecat-migrate/platforms/ios.md | 86 ++++++ .../revenuecat-migrate/platforms/kmp.md | 61 +++++ .../platforms/react-native.md | 85 ++++++ .../skills/revenuecat-paywall/SKILL.md | 57 ++++ .../revenuecat-paywall/platforms/android.md | 114 ++++++++ .../revenuecat-paywall/platforms/flutter.md | 121 +++++++++ .../revenuecat-paywall/platforms/ios.md | 135 ++++++++++ .../revenuecat-paywall/platforms/kmp.md | 96 +++++++ .../platforms/react-native.md | 112 ++++++++ .../skills/revenuecat-purchase-flow/SKILL.md | 50 ++++ .../platforms/android.md | 120 +++++++++ .../platforms/flutter.md | 120 +++++++++ .../revenuecat-purchase-flow/platforms/ios.md | 101 +++++++ .../revenuecat-purchase-flow/platforms/kmp.md | 76 ++++++ .../platforms/react-native.md | 97 +++++++ .../skills/revenuecat-status/SKILL.md | 79 ++++++ .../skills/revenuecat-testing-setup/SKILL.md | 94 +++++++ .../platforms/android.md | 136 ++++++++++ .../platforms/flutter.md | 98 +++++++ .../revenuecat-testing-setup/platforms/ios.md | 130 +++++++++ .../revenuecat-testing-setup/platforms/kmp.md | 71 +++++ .../platforms/react-native.md | 117 ++++++++ .../skills/revenuecat-troubleshoot/SKILL.md | 254 ++++++++++++++++++ .../platforms/android.md | 80 ++++++ .../platforms/flutter.md | 70 +++++ .../revenuecat-troubleshoot/platforms/ios.md | 68 +++++ .../revenuecat-troubleshoot/platforms/kmp.md | 59 ++++ .../platforms/react-native.md | 84 ++++++ plugins/revenuecat/skills/revenuecat/SKILL.md | 6 + 63 files changed, 5483 insertions(+) create mode 100644 plugins/revenuecat/NOTICE.revenuecat-ai-toolkit create mode 100644 plugins/revenuecat/README.md create mode 100644 plugins/revenuecat/index.ts create mode 100644 plugins/revenuecat/package.json create mode 100644 plugins/revenuecat/skills/create-revenuecat-project/SKILL.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/SKILL.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/platforms/android.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/platforms/ios.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/integrate-revenuecat/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-charts/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-customer-center/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-identify-user/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-migrate/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-paywall/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-status/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-testing-setup/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/SKILL.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/platforms/android.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/platforms/flutter.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/platforms/ios.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/platforms/kmp.md create mode 100644 plugins/revenuecat/skills/revenuecat-troubleshoot/platforms/react-native.md create mode 100644 plugins/revenuecat/skills/revenuecat/SKILL.md diff --git a/README.md b/README.md index a400e81c..a614a4e2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Each plugin lives in `plugins/`. The directory name is the install keyword | `linear` | Linear SDK scripting skill for issue, project, team, cycle, and comment workflows. | | `mac-notify` | macOS notifications when a Cline run completes. | | `nanobanana` | Image generation through OpenRouter and Gemini image models. | +| `revenuecat` | RevenueCat MCP plus in-app purchase, entitlement, paywall, analytics, and troubleshooting skills. | | `speak` | Speaks completed Cline replies with ElevenLabs text to speech. | | `typescript-lsp` | TypeScript language service `goto_definition` support. | | `weather-metrics` | Demo weather tool plus runtime metrics hooks. | diff --git a/plugins/revenuecat/NOTICE.revenuecat-ai-toolkit b/plugins/revenuecat/NOTICE.revenuecat-ai-toolkit new file mode 100644 index 00000000..39736f19 --- /dev/null +++ b/plugins/revenuecat/NOTICE.revenuecat-ai-toolkit @@ -0,0 +1,24 @@ +This plugin includes RevenueCat workflow skill material adapted from the RevenueCat AI toolkit. + +Source: https://github.com/RevenueCat/ai-toolkit +License: MIT + +Copyright (c) RevenueCat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/revenuecat/README.md b/plugins/revenuecat/README.md new file mode 100644 index 00000000..b7623063 --- /dev/null +++ b/plugins/revenuecat/README.md @@ -0,0 +1,29 @@ +# revenuecat + +Use RevenueCat from Cline for in-app purchase setup, subscription analytics, entitlement checks, paywalls, customer center flows, purchase testing, migration, and troubleshooting. + +## What It Adds + +The plugin registers the RevenueCat remote MCP server and bundles RevenueCat workflow skills for: + +- Creating projects, apps, products, entitlements, offerings, and packages. +- Integrating RevenueCat SDKs in iOS, Android, Kotlin Multiplatform, Flutter, and React Native apps. +- Building paywalls, purchase flows, entitlement gates, customer center screens, and identify/log-in flows. +- Reading RevenueCat charts and project status. +- Testing purchases and troubleshooting dashboard or SDK issues. + +## Requirements + +RevenueCat MCP access requires RevenueCat account authentication. Interactive installs may offer to authorize the RevenueCat MCP server immediately; non-interactive installs can authorize later from `cline mcp`. + +Project configuration changes depend on the user's RevenueCat account permissions. App-side SDK changes may require local platform tooling such as Xcode, Android Studio, Gradle, Flutter, or React Native tooling depending on the target app. + +## Safety Notes + +RevenueCat controls monetization and customer access. The plugin includes a rule that asks Cline to confirm before changing projects, apps, products, entitlements, offerings, packages, webhooks, API keys, pricing, or SDK configuration. + +Public SDK keys are safe to embed in client apps. Secret API keys are server-side only and should never be written into client code. + +## License + +The bundled RevenueCat workflow skills are adapted from RevenueCat's AI toolkit under the MIT license. See `NOTICE.revenuecat-ai-toolkit`. diff --git a/plugins/revenuecat/index.ts b/plugins/revenuecat/index.ts new file mode 100644 index 00000000..7ccd6f4a --- /dev/null +++ b/plugins/revenuecat/index.ts @@ -0,0 +1,34 @@ +import type { AgentPlugin } from "@cline/sdk" + +const REVENUECAT_MCP_URL = "https://mcp.revenuecat.ai/mcp" + +const plugin: AgentPlugin = { + name: "revenuecat", + manifest: { + capabilities: ["mcp", "skills", "rules"], + }, + + setup(api) { + api.registerMcpServer({ + name: "revenuecat", + transport: { + type: "streamableHttp", + url: REVENUECAT_MCP_URL, + }, + }) + + api.registerRule({ + id: "revenuecat:monetization-safety", + source: "revenuecat", + content: [ + "RevenueCat workflows can affect app monetization, customer access, analytics, webhooks, and store configuration.", + "Before creating or changing projects, apps, products, entitlements, offerings, packages, webhooks, API keys, pricing, or SDK configuration, explain the intended change and get explicit user confirmation.", + "Always list available projects first when using RevenueCat tools. If more than one project is available, ask the user which project to use before making project-scoped reads or writes.", + "Never place RevenueCat secret API keys in client code. Public SDK keys can be embedded in apps; secret keys are server-side only.", + "Treat revenue metrics, customer data, subscription status, and purchase history as sensitive business data. Summarize only what is needed for the user's task.", + ].join("\n"), + }) + }, +} + +export default plugin diff --git a/plugins/revenuecat/package.json b/plugins/revenuecat/package.json new file mode 100644 index 00000000..3bd9b5fe --- /dev/null +++ b/plugins/revenuecat/package.json @@ -0,0 +1,21 @@ +{ + "name": "revenuecat", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Cline plugin for RevenueCat MCP access and in-app purchase monetization workflows.", + "cline": { + "plugins": [ + { + "paths": [ + "./index.ts" + ], + "capabilities": [ + "mcp", + "skills", + "rules" + ] + } + ] + } +} diff --git a/plugins/revenuecat/skills/create-revenuecat-project/SKILL.md b/plugins/revenuecat/skills/create-revenuecat-project/SKILL.md new file mode 100644 index 00000000..89b1d5eb --- /dev/null +++ b/plugins/revenuecat/skills/create-revenuecat-project/SKILL.md @@ -0,0 +1,89 @@ +--- +name: create-revenuecat-project +description: "Set up a complete RevenueCat project from scratch - creates apps, products, entitlements, offerings, and packages in the correct order. Use when the user wants to create a new RevenueCat project, configure in-app purchases, set up subscriptions or monetization, or bootstrap IAP infrastructure for iOS, Android, or Web." +--- + +# RevenueCat Project Bootstrap + +Guide through setting up a complete RevenueCat project from scratch. + +## Instructions + +Important: Use the RevenueCat MCP server for all tool calls. The MCP server may have access to multiple projects. Always use `list-projects` first to retrieve all accessible projects. If multiple projects are returned, ask the user which project to use or if they want to create a new one. + +### Phase 1: Discovery + +Ask targeted questions to understand the developer's needs: + +1. Platforms - "Which platforms are you building for?" (iOS, Android, Web, or multiple) +2. Business Model - "What type of monetization are you planning?" (subscriptions, one-time purchases, consumables, or a mix) +3. Subscription Tiers (if applicable) - "What subscription options do you want to offer?" (common: Monthly + Annual, single tier, Freemium + Premium) +4. App Details - Bundle ID (iOS, e.g. `com.company.appname`), package name (Android), and display name + +### Phase 2: Create Resources + +Execute in this order - dependencies matter. + +1. Verify/Create Project +`list-projects` - list accessible projects +If multiple: ask user which to use, or offer to create a new one +To create a new project, use the `create-project` MCP tool +Store project_id for all subsequent calls + +2. Create Apps (for each platform): + - For mobile apps, ask if the user already has set up their app in App Store Connect / Google Play Console. If so, create an app using the `create-app` tool (type: app_store | play_store). If not, use the automatically generated `test_store` app and tell the user that they can set up the integration with App Store Connect / Google Play Console later. + - For web apps, `create-app` with type rc_billing + +3. Create Products (for each subscription/purchase): `create-product` tool + +4. Create Entitlements (for each feature/access level): `create-entitlement` tool + +5. Attach Products to Entitlements: `attach-products-to-entitlement` tool + +6. Create Default Offering: `create-offering` tool (lookup_key: "default") + +7. Create Packages in Offering: `create-package` tool (for subscriptions, use $rc_monthly, $rc_annual, etc.) + +8. Attach Products to Packages: `attach-products-to-package` tool + +9. Get API keys: use the RevenueCat MCP public API key listing tool for each app + +### Phase 3: Summary & Next Steps + +Provide a complete setup summary: + +``` +Project Setup Complete! +======================= + +Project: {project_name} ({project_id}) + +Apps Created: + iOS: {app_name} - API Key: appl_xxxxx + Android: {app_name} - API Key: goog_xxxxx + +Products: + - monthly_premium (subscription, P1M) + - annual_premium (subscription, P1Y) + +Entitlements: + - premium -> monthly_premium, annual_premium + +Offering: default (current) + $rc_monthly -> monthly_premium + $rc_annual -> annual_premium + +Next Steps: +1. Configure store credentials in RevenueCat dashboard +2. Create products in App Store Connect / Play Console +3. Add the SDK to your app with the `integrate-revenuecat` skill +4. Implement paywall UI using the "default" offering +``` + +## Error Handling + +If any step fails: +1. Report the specific error clearly +2. Suggest fixes (e.g., "Bundle ID may already be in use") +3. Offer to retry or skip that step +4. Continue with remaining steps if possible diff --git a/plugins/revenuecat/skills/integrate-revenuecat/SKILL.md b/plugins/revenuecat/skills/integrate-revenuecat/SKILL.md new file mode 100644 index 00000000..33c079c3 --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/SKILL.md @@ -0,0 +1,127 @@ +--- +name: integrate-revenuecat +description: End-to-end RevenueCat integration - sets up the dashboard side via the RevenueCat MCP (project, app, public API key) and installs/configures the Purchases SDK in the app. Use when the user asks to add RevenueCat, integrate Purchases, install the RevenueCat SDK, set up a RevenueCat API key, configure Purchases on launch, or set up a brand new RevenueCat integration on iOS, Android, Kotlin Multiplatform, Flutter, or React Native. +--- + +# integrate-revenuecat: end-to-end RevenueCat integration + +Use this skill when the user wants to add RevenueCat to a project for the first time, or to reconfigure the SDK with a public API key. The skill covers two halves: + +1. Dashboard side - set up the project, register the app, and obtain the public API key, all through the RevenueCat MCP server. +2. App side - install the Purchases SDK, call `Purchases.configure(...)` at app entry, and verify the configuration banner in the logs. + +Walk them in order. Most integrations need both halves, even when the user asks "just install the SDK" - the SDK needs an API key from the dashboard. + +> If a project + app already exist and the user only wants to wire the SDK into code, jump to Section 3 below. +> If the user wants to bootstrap a brand new RevenueCat project (apps + products + entitlements + offerings), use the `create-revenuecat-project` skill instead, then come back here for the SDK install. + +## Optional Context + +The user may provide a platform (`ios`, `android`, `kmp`, `flutter`, or `react-native`), an app identifier, or a project name in their request. If any are missing, inspect the workspace first and ask only for details that cannot be inferred. + +## 1. Understand the status quo + +Before touching the dashboard, gather the facts: + +- Platform target: iOS / Apple App Store, Android / Google Play, or both. Inspect the working directory before asking - the detection algorithm in Section 3 makes this obvious for most projects. +- Technology: native iOS (Swift), native Android (Kotlin / Java), React Native, Flutter, Kotlin Multiplatform. SDK list: https://www.revenuecat.com/docs/getting-started/installation.md. +- App identifier: bundle ID (iOS), package name (Android). Pull from `Info.plist` / `AndroidManifest.xml` / `app.json` / `pubspec.yaml` rather than asking. + +## 2. Dashboard side - RevenueCat MCP + +Use the RevenueCat MCP server for every tool call below. + +### 2a. Get or create the project +- `list-projects` - list accessible projects. If multiple, ask the user which one matches this app, or offer to create a new one. +- If there is no project, hand off to the `create-revenuecat-project` skill, then resume here. +- Store the `project_id` for the rest of the steps. + +### 2b. Get or create the app +- Check which apps are already configured in the project. A `test_store` app is always present; `app_store` and `play_store` apps are present only if the user has finished store-side setup. +- Ask the user whether their app is already set up in App Store Connect (iOS) or Google Play Console (Android). Reassure them that store-side setup can come later - the `test_store` app is enough to start integrating. +- If the user confirms store-side setup is done, call `create-app`: + - iOS: `type: "app_store"`, `bundle_id` from Section 1. + - Android: `type: "play_store"`, `package_name` from Section 1. + - `name` derived from the identifier or asked from the user. + +### 2c. Get the public API key +- Use the RevenueCat MCP public API key listing tool with the relevant app ID: + - `app_store` / `play_store` if the store-side app exists. + - Otherwise the `test_store` app. +- The returned key is public and safe to embed in client app code. iOS keys are prefixed `appl_...`, Android keys `goog_...`, Amazon `amzn_...`. + +> Never use the secret API key in client code. Secret keys are server-side only. + +## 3. App side - install and configure the SDK + +### 3a. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency -> read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root -> read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*` -> read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP) -> read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root -> read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +### 3b. Shared concepts (all platforms) + +- Public SDK key, not secret key. RevenueCat issues a separate public SDK key per store/platform. iOS apps use an `appl_...` key, Android apps use a `goog_...` key (Amazon uses `amzn_...`). Server-side secret keys must never appear in client apps. +- Configure once per app launch. Call `Purchases.configure(...)` exactly once, as early as possible (app entry point). Later calls no-op or warn. +- Anonymous users by default. If you don't pass an `appUserID`, RevenueCat creates a stable anonymous ID. Only pass `appUserID` if you already have an authenticated user at launch; otherwise call `logIn(...)` later (see the `revenuecat-identify-user` skill). +- Enable debug logging during integration. Each platform file shows how. Turn it off for release builds. +- Keep keys out of source control. Recommend `.env` (RN), `xcconfig` (iOS), `local.properties` / `gradle.properties` (Android), or dart-define (Flutter) when the user asks about secret management. + +### 3c. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file is self-contained: install command, exact `configure` snippet, and where to place it in the app entry point. + +## 4. Verify + +Do not claim setup is complete until: + +1. The project builds (Xcode build, `./gradlew assembleDebug`, `flutter run`, `npx react-native run-ios`, or the KMP equivalent). +2. The app launches and the RevenueCat SDK logs a configuration banner in the console / logcat / Metro output (each platform file describes the expected log line). +3. No authentication errors appear on the first SDK network call. A wrong API key surfaces as an auth error log as soon as the app fetches offerings. + +If the user only asked to "install" without running the app, tell them what to look for in the logs when they do run it. + +## 5. Next steps + +### 5a. Products, entitlements, offerings +Check whether products, entitlements, and offerings are already set up in the project. If not, offer to help via the `create-revenuecat-project` skill. + +### 5b. Store-side setup + +iOS (App Store Connect) + +1. In-App Purchase Key (recommended for StoreKit 2) - App Store Connect -> Users and Access -> Integrations -> In-App Purchase. Generate key, download the `.p8` file. Note the Key ID and Issuer ID. +2. Shared Secret (legacy StoreKit 1) - App Store Connect -> App -> App Information -> App-Specific Shared Secret. +3. If the user provides this information, register it on the RevenueCat side via `create-app` / `update-app`. + +Android (Google Play Console) + +1. Service account credentials - Create a service account in Google Cloud Console. Grant "Service Account User" role. Create a JSON key. In Play Console, grant the service account access with "View financial data" permission. +2. Real-time Developer Notifications (RTDN) - Set up a Cloud Pub/Sub topic. Configure in Play Console -> Monetization setup. +3. If the user provides this information, register it via `create-app` / `update-app`. + +### 5c. Subsequent skills + +Common follow-ups after `integrate-revenuecat`: + +- `revenuecat-paywall` - display a dashboard-configured paywall. +- `revenuecat-purchase-flow` - implement purchase + restore manually. +- `revenuecat-entitlements-gate` - gate features behind active entitlements. +- `revenuecat-identify-user` - wire `logIn` / `logOut` to the app's auth system. +- `revenuecat-testing-setup` - set up a sandbox testing channel. +- `revenuecat-troubleshoot` - diagnose offerings / products / entitlement bugs. diff --git a/plugins/revenuecat/skills/integrate-revenuecat/platforms/android.md b/plugins/revenuecat/skills/integrate-revenuecat/platforms/android.md new file mode 100644 index 00000000..831d57a3 --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/platforms/android.md @@ -0,0 +1,80 @@ +# integrate-revenuecat: Android (native Kotlin/Java) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +### Gradle (Kotlin DSL) + +In the app module's `build.gradle.kts`: + +```kotlin +dependencies { + implementation("com.revenuecat.purchases:purchases:") + // implementation("com.revenuecat.purchases:purchases-ui:") // optional, native paywalls +} +``` + +### Gradle (Groovy DSL) + +```groovy +dependencies { + implementation 'com.revenuecat.purchases:purchases:' + // implementation 'com.revenuecat.purchases:purchases-ui:' +} +``` + +`mavenCentral()` must be in `settings.gradle(.kts)` -> `dependencyResolutionManagement.repositories` (it's there by default for projects created with recent Android Studio templates). + +## Configure + +Create a custom `Application` class so `configure` runs before any `Activity`. + +### `MyApplication.kt` + +```kotlin +package com.example.myapp + +import android.app.Application +import com.revenuecat.purchases.LogLevel +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesConfiguration + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + Purchases.logLevel = LogLevel.DEBUG // remove for release + Purchases.configure( + PurchasesConfiguration.Builder(this, "goog_YOUR_PUBLIC_SDK_KEY") + .build() + ) + } +} +``` + +### Register it in `AndroidManifest.xml` + +```xml + + ... + +``` + +## Notes + +- Use the Google Play public SDK key: it starts with `goog_` (Amazon Appstore keys start with `amzn_`). +- The SDK declares `com.android.vending.BILLING` in its own manifest; you do not need to add it. +- Minimum SDK: 21. Confirm `minSdk` in the app module's `build.gradle(.kts)`. +- Proguard/R8: the SDK ships consumer rules, no extra config needed. + +## Verify + +Run the app. In logcat, filter by tag `Purchases` and look for: + +``` +Purchases: [Purchases] - INFO: Purchases is configured +``` + +A wrong API key shows up as an auth error on the first offerings fetch. No logs at all usually means `android:name=".MyApplication"` is missing from the manifest. diff --git a/plugins/revenuecat/skills/integrate-revenuecat/platforms/flutter.md b/plugins/revenuecat/skills/integrate-revenuecat/platforms/flutter.md new file mode 100644 index 00000000..b5960b8c --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/platforms/flutter.md @@ -0,0 +1,80 @@ +# integrate-revenuecat: Flutter + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing `pubspec.yaml` for one. + +In `pubspec.yaml`: + +```yaml +dependencies: + purchases_flutter: ^ + # purchases_ui_flutter: ^ # optional, for native paywalls +``` + +Then: + +```bash +flutter pub get +``` + +### iOS target + +```bash +cd ios && pod install && cd .. +``` + +Minimum iOS deployment target is 13.0. Update `ios/Podfile`: + +```ruby +platform :ios, '13.0' +``` + +### Android target + +Minimum SDK is 21. In `android/app/build.gradle`: + +```groovy +defaultConfig { + minSdk 21 +} +``` + +## Configure + +In `lib/main.dart`, configure before `runApp` so the rest of the app can rely on the SDK being up: + +```dart +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Purchases.setLogLevel(LogLevel.debug); // remove for release + + final apiKey = Platform.isIOS + ? 'appl_YOUR_IOS_PUBLIC_SDK_KEY' + : 'goog_YOUR_ANDROID_PUBLIC_SDK_KEY'; + + await Purchases.configure(PurchasesConfiguration(apiKey)); + + runApp(const MyApp()); +} +``` + +## Notes + +- Two public SDK keys, one per platform. Branch on `Platform.isIOS` / `Platform.isAndroid`. +- `Purchases.configure` is async; `await` it before `runApp` or any code that reads offerings. +- `purchases_flutter` supports iOS and Android only. Web, macOS, Windows, and Linux targets are not supported. +- For a multi flavor app, load the API key from `--dart-define` or an environment wrapper rather than hard coding it. + +## Verify + +`flutter run`. Expect the native SDK log banner in the Dart/native console: + +- iOS simulator/device -> Xcode console also shows `[Purchases] - INFO: Purchases is configured` +- Android emulator/device -> `flutter logs` (or `adb logcat`) shows `Purchases: [Purchases] - INFO: Purchases is configured` diff --git a/plugins/revenuecat/skills/integrate-revenuecat/platforms/ios.md b/plugins/revenuecat/skills/integrate-revenuecat/platforms/ios.md new file mode 100644 index 00000000..f5d3256f --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/platforms/ios.md @@ -0,0 +1,100 @@ +# integrate-revenuecat: iOS (native) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +Pick the dependency manager already in use. + +### Swift Package Manager (preferred) + +In Xcode: File -> Add Package Dependencies..., enter: + +``` +https://github.com/RevenueCat/purchases-ios +``` + +Pick the version you resolved above and add the `RevenueCat` product to your app target. Also add `RevenueCatUI` if the user will want native paywalls later. + +For a `Package.swift`-based project: + +```swift +dependencies: [ + .package(url: "https://github.com/RevenueCat/purchases-ios", from: "") +], +targets: [ + .target( + name: "MyApp", + dependencies: [ + .product(name: "RevenueCat", package: "purchases-ios"), + // .product(name: "RevenueCatUI", package: "purchases-ios"), + ] + ) +] +``` + +### CocoaPods + +```ruby +# Podfile +pod 'RevenueCat' +# pod 'RevenueCatUI' # optional, for native paywalls +``` + +Then `pod install`. + +## Configure + +Call `Purchases.configure(withAPIKey:)` once at app launch. + +### SwiftUI `App` + +```swift +import SwiftUI +import RevenueCat + +@main +struct MyApp: App { + init() { + Purchases.logLevel = .debug // remove for release + Purchases.configure(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY") + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + +### UIKit `AppDelegate` + +```swift +import UIKit +import RevenueCat + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Purchases.logLevel = .debug + Purchases.configure(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY") + return true + } +} +``` + +## Notes + +- Use the iOS public SDK key: it starts with `appl_`. Find it in the RevenueCat dashboard under Project -> API keys. +- Deployment target: async/await SDK APIs require iOS 13+. For older targets, completion handler variants exist (`getOfferings(completion:)`, `purchase(product:completion:)`). +- For sandbox testing with a StoreKit Configuration File, attach it to the scheme (Run -> Options -> StoreKit Configuration). See `revenuecat-testing-setup` when available. + +## Verify + +Build and run. In the Xcode console look for: + +``` +[Purchases] - INFO: Purchases is configured +``` + +A wrong API key shows up as an auth error log on the first `getOfferings` call. If you see no Purchases logs at all, `Purchases.logLevel = .debug` is missing or the configure call isn't running at launch. diff --git a/plugins/revenuecat/skills/integrate-revenuecat/platforms/kmp.md b/plugins/revenuecat/skills/integrate-revenuecat/platforms/kmp.md new file mode 100644 index 00000000..ea7e687d --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/platforms/kmp.md @@ -0,0 +1,93 @@ +# integrate-revenuecat: Kotlin Multiplatform + +`purchases-kmp` is a thin Kotlin Multiplatform wrapper over the native iOS and Android SDKs. Behavior matches the native SDKs; only the entry point differs. + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. The KMP tag uses a `+` format (e.g. `2.10.2+17.55.1`), where the part before `+` is the KMP wrapper version and the part after is the bundled `purchases-hybrid-common` version. Use the full tag string as the artifact version first; if Gradle interprets the `+` as a wildcard, fall back to the wrapper portion only (e.g. `2.10.2`). If GitHub is unreachable, ask the user for a version to pin. + +In the shared module's `build.gradle.kts`: + +```kotlin +kotlin { + // ... your targets (androidTarget(), iosX64(), iosArm64(), iosSimulatorArm64(), etc.) + + sourceSets { + commonMain.dependencies { + implementation("com.revenuecat.purchases:purchases-kmp-core:") + // implementation("com.revenuecat.purchases:purchases-kmp-ui:") // Compose Multiplatform paywalls + } + } +} +``` + +Add `mavenCentral()` to your project repositories if it isn't already there. + +### iOS linking + +`purchases-kmp` bridges to `purchases-ios` on iOS. If you use CocoaPods with the Kotlin CocoaPods plugin, the bridge pod is wired automatically. For pure Swift Package Manager setups, follow the iOS target instructions in the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp#readme). The exact shape evolves per release. + +## Configure + +Call `Purchases.configure(...)` once per platform, as early as possible in each platform's entry point. The API key differs per store, so pass the right one on each platform. + +### Shared entry point (`commonMain`) + +```kotlin +import com.revenuecat.purchases.kmp.LogLevel +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.PurchasesConfiguration + +fun initRevenueCat(apiKey: String) { + Purchases.logLevel = LogLevel.DEBUG // remove for release + Purchases.configure( + PurchasesConfiguration.Builder(apiKey = apiKey).build() + ) +} +``` + +### Android: call from `Application.onCreate()` + +On Android, `PurchasesConfiguration` needs a `Context`. The KMP SDK provides a platform specific overload that takes the Android `Context` as the first argument. Use it from your `Application` class: + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + // The purchases-kmp Android actual of PurchasesConfiguration takes a Context. + // Pass it here before forwarding to the shared initRevenueCat, or construct + // the PurchasesConfiguration inline on this Android-only path. + initRevenueCat("goog_YOUR_ANDROID_PUBLIC_SDK_KEY") + } +} +``` + +> If the `Purchases.configure(...)` signature in your installed version of purchases-kmp requires a different shape on Android (e.g. `PurchasesConfiguration(context, apiKey)`), follow what the IDE autocompletes. The KMP SDK's `expect`/`actual` surface has changed across versions. Don't guess. + +### iOS: call from `@main App` (SwiftUI) + +```swift +import shared // the KMP framework produced from your shared module + +@main +struct iOSApp: App { + init() { + MainKt.initRevenueCat(apiKey: "appl_YOUR_IOS_PUBLIC_SDK_KEY") + } + + var body: some Scene { WindowGroup { ContentView() } } +} +``` + +## Notes + +- Two public SDK keys: `appl_...` for iOS, `goog_...` for Android. Keep them separate. +- Because the KMP SDK wraps the native SDKs, the verify logs below are the native SDK logs. See `ios.md` / `android.md` for the exact log line on each side. +- When in doubt about the exact shape of `PurchasesConfiguration`, check the installed version's source or the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp). The skill prefers "accurate for your version" over "plausibly correct in general." + +## Verify + +Run each platform target. Expect: + +- iOS: `[Purchases] - INFO: Purchases is configured` in Xcode console. +- Android: `Purchases: [Purchases] - INFO: Purchases is configured` in logcat. diff --git a/plugins/revenuecat/skills/integrate-revenuecat/platforms/react-native.md b/plugins/revenuecat/skills/integrate-revenuecat/platforms/react-native.md new file mode 100644 index 00000000..a64beb8a --- /dev/null +++ b/plugins/revenuecat/skills/integrate-revenuecat/platforms/react-native.md @@ -0,0 +1,62 @@ +# integrate-revenuecat: React Native + +## Install + +The npm install commands below resolve the current latest at install time, so no version pin is needed in this skill. To verify the installed version after install, check `package.json`. The full release history lives at . + +### Bare React Native + +```bash +npm install react-native-purchases +# npm install react-native-purchases-ui # optional, for native paywalls +cd ios && pod install && cd .. +``` + +### Expo + +```bash +npx expo install react-native-purchases +# npx expo install react-native-purchases-ui +``` + +`react-native-purchases` requires a development build. It will not work in Expo Go because it links native code. Produce a dev client with `npx expo prebuild` (bare workflow) or `eas build --profile development`. + +## Configure + +Call `Purchases.configure` once when the app mounts. In `App.tsx` (or `index.js`): + +```tsx +import { useEffect } from 'react'; +import { Platform } from 'react-native'; +import Purchases, { LOG_LEVEL } from 'react-native-purchases'; + +export default function App() { + useEffect(() => { + Purchases.setLogLevel(LOG_LEVEL.DEBUG); // remove for release + + const apiKey = Platform.OS === 'ios' + ? 'appl_YOUR_IOS_PUBLIC_SDK_KEY' + : 'goog_YOUR_ANDROID_PUBLIC_SDK_KEY'; + + Purchases.configure({ apiKey }); + }, []); + + return /* ... your UI ... */; +} +``` + +## Notes + +- Two public SDK keys, one per platform. Branch on `Platform.OS`. +- Deployment targets: iOS 13+, Android minSdk 21. +- `Purchases.configure` is synchronous; it kicks off async initialization internally. You can call `getOfferings()` right after without awaiting configure. +- If running under Expo, confirm the user is on a dev client (not Expo Go) before testing. Purchase APIs will throw otherwise. + +## Verify + +Run the app. Expect the native SDK logs: + +- iOS -> Xcode console: `[Purchases] - INFO: Purchases is configured` +- Android -> Android Studio logcat (or `adb logcat`) with tag `Purchases`: ` [Purchases] - INFO: Purchases is configured` + +Metro bundler (JS) console will not show the native SDK logs; you need the platform logs. diff --git a/plugins/revenuecat/skills/revenuecat-charts/SKILL.md b/plugins/revenuecat/skills/revenuecat-charts/SKILL.md new file mode 100644 index 00000000..32497149 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-charts/SKILL.md @@ -0,0 +1,186 @@ +--- +name: revenuecat-charts +description: Use when the user asks about RevenueCat data, analytics, charts, KPIs +--- + +# Accessing RevenueCat charts +Use the following two tools of the RevenueCat MCP: +- `get-chart-options-schema`: To understand the available options for each chart, including date resolution, segments, filters, and other selectors +- `get-chart-data`: To retrieve data for a chart + +In general, to avoid clogging the context, start with defined timeframes and larger resolution, then narrow down. + + +# Interpreting metrics + +Subscription apps are driven by four forces: + +Acquisition - how many new customers are arriving to the app +Conversion - how many of those customers are converting into trials or paid paid plans +Retention - how long do those customers retain +Reactivation - how can you bring back old users + +The net movement of an apps revenue will be the result of the combination of these forces. When giving advice, always use benchmark data to make sure you aren't incorrectly diagnosing an issue. + +## Acquisition + +- Use the New Customers chart to understand how much top of funnel the app is driving. +- Segmenting New Customers by Country, or Apple Search Ads dimensions can be helpful in informing acquisition + +## Conversion + +The definition of conversion may vary depending on what model the app is using. They may be converting to a trial, that then converts into a subscription. Or they may be sending users directly to a subscription. + +- Use the Initial Conversion chart to see how many trial or subscriptions are started. +- You can then further determine if they are using free trials by comparing that to the New Trials chart +- The Trial Conversion Rate chart is a helpful chart for understanding the performance of just that trial conversion + +## Retention + +- The Churn chart will tell you the % of the active subscriber base that is lost each period. It can be difficult to interpret or benchmark because it is a blend of different periods. +- When you want to understand the long term retention of different products, look at the Subscription Retention chart + +## Reactivation + +- The only real way to understand Reactivation is looking at the MRR Movement chart and the Resubscription MRR + + +## Dashboard URL Format + +Use this exact structure: + +``` +https://app.revenuecat.com/projects/{project_id}/charts/{chart_name}?range={range_value} +``` + +- `{project_id}` - The short hex ID (e.g., `56965ae1`), NOT the full `proj56965ae1` +- `{chart_name}` - Chart name like `revenue`, `churn`, `mrr`, etc. +- Project ID goes in the path, not as a query parameter + +Correct example: + +``` +https://app.revenuecat.com/projects/56965ae1/charts/revenue?range=Last+90+days%3A{start_date}%3A{end_date} +``` + +Do not use: + +``` +https://app.revenuecat.com/charts/revenue?project=proj56965ae1&chart_start=...&chart_end=... +``` + +## Query Parameters + +### Date Range (`range`) - REQUIRED + +The `range` parameter controls the date range. Format: `{preset}:{start_date}:{end_date}` + +You must use this format - do NOT use `start_date`, `end_date`, `chart_start`, or `chart_end` params. + +| Preset | Encoded Value | +| ------------- | -------------------------------------------------- | +| Last 7 days | `range=Last+7+days%3A{start_date}%3A{end_date}` | +| Last 28 days | `range=Last+28+days%3A{start_date}%3A{end_date}` | +| Last 90 days | `range=Last+90+days%3A{start_date}%3A{end_date}` | +| Last 365 days | `range=Last+365+days%3A{start_date}%3A{end_date}` | +| Custom | `range=Custom%3A{start_date}%3A{end_date}` | + +Note: The `:` between parts must be URL-encoded as `%3A`. Spaces become `+`. +Always compute `{end_date}` from the current date unless the user specifies a different end date, then compute `{start_date}` from the selected preset or custom range. + +### Resolution (`resolution`) + +| Value | Meaning | +| --------- | --------------------- | +| `day` | Daily granularity | +| `week` | Weekly granularity | +| `month` | Monthly granularity | +| `quarter` | Quarterly granularity | +| `year` | Yearly granularity | + +### Segment (`segment_by`) + +Dimension to break down the data by. Common values: + +- `country` - by country +- `store` - by app store (App Store, Play Store, etc.) +- `product` - by product identifier +- `platform` - by platform (iOS, Android, etc.) +- `offering` - by offering + +### Filters + +Filters are passed as individual query params with the filter name as key: + +| Filter | Example | +| -------------------- | ------------------------------------ | +| `country` | `country=US` | +| `store` | `store=app_store` | +| `product_identifier` | `product_identifier=premium_monthly` | +| `platform` | `platform=iOS` | + +Multiple values for the same filter: `country=US&country=DE&country=JP` + +### Chart-Specific Selectors + +Some charts have special selectors: + +Conversion/Retention charts: + +- `customer_lifetime` - e.g., `30+days`, `60+days`, `90+days` +- `conversion_timeframe` - e.g., `7+days`, `14+days`, `30+days` + +Workflow charts: + +- `path` - workflow path filter +- `workflows_customer_lifetime` - e.g., `initial` + +## Constructing a Link + +To generate a dashboard link: + +1. Start with base: `https://app.revenuecat.com/projects/{project_id}/charts/{chart_name}` +2. Add `range` param with date range +3. Add any filters as query params +4. Add `segment_by` if segmenting +5. Add chart-specific selectors as needed +6. URL-encode all values (spaces -> `+`, colons -> `%3A`, etc.) + +## API to Dashboard Parameter Mapping + +When translating from API parameters to dashboard URLs: + +| API Parameter | Dashboard Parameter | +| ------------------------- | ------------------------------------------------------ | +| `start_date` + `end_date` | `range=Custom%3A{start}%3A{end}` (use `Custom` preset) | +| `segment` | `segment_by` | +| `filters` (JSON array) | Individual query params | +| `selectors` (JSON object) | Individual query params | + +Note: Do NOT pass `resolution` as a numeric value. The resolution is typically implied by the range preset or omitted. + +## Example: Building a Link + +User wants: "Revenue chart for last 90 days, segmented by country, filtered to US and Germany" + +Calculate dates from the current date before building the URL. + +``` +https://app.revenuecat.com/projects/56965ae1/charts/revenue?range=Last+90+days%3A{start_date}%3A{end_date}&segment_by=country&country=US&country=DE +``` + +User wants: "Churn chart from August 2025 to now" + +Use the `Custom` preset for arbitrary date ranges: + +``` +https://app.revenuecat.com/projects/56965ae1/charts/churn?range=Custom%3A2025-08-01%3A{end_date} +``` + +## Getting Project ID + +The project ID can be found via the API: + +- `GET /projects` - lists all projects with their IDs +- API returns IDs like `proj56965ae1` +- For dashboard URLs, strip the `proj` prefix - use just `56965ae1` in the path diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/SKILL.md b/plugins/revenuecat/skills/revenuecat-customer-center/SKILL.md new file mode 100644 index 00000000..3e71806b --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/SKILL.md @@ -0,0 +1,54 @@ +--- +name: revenuecat-customer-center +description: "Add the RevenueCat Customer Center (self service subscription management UI) to an app. Use when the user asks to add a customer center, build a self service subscriptions screen, let users manage subscriptions in app, add a subscription management screen, present CustomerCenterView, call presentCustomerCenter, or wire a 'manage subscription' button to the RevenueCat customer center on iOS, Android, Kotlin Multiplatform, Flutter, or React Native." +--- + +# revenuecat-customer-center: add the RevenueCat Customer Center + +Use this skill when the user wants an out of the box UI that lets their customers manage active subscriptions, request refunds, cancel, restore, or contact support, without shipping custom UI. The UI is configured in the RevenueCat dashboard and rendered by the `RevenueCatUI` SDKs. + +Prerequisite: `integrate-revenuecat` has already run. `Purchases.configure(...)` must succeed before the Customer Center can load customer data. + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency. The Customer Center ships in `react-native-purchases-ui`. Read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root. The Customer Center ships in `purchases_ui_flutter`. Read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` has a `kotlin { ... }` multiplatform block, or depends on `com.revenuecat.purchases:purchases-kmp*`. The Customer Center composable is in `purchases-kmp-ui`. Read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP). The Customer Center composable is in `com.revenuecat.purchases:purchases-ui`. Read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root. `CustomerCenterView` is in `RevenueCatUI`. Read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Shared concepts (all platforms) + +- Customer Center is a dashboard configured UI. Actions, copy, promotional offers, refund flows, cancel survey options, and support contact details are defined in the RevenueCat dashboard under Customer Center. Without configuration, the view renders a minimal default layout; users will see almost nothing useful. +- It needs an identified user with purchases to surface anything meaningful. If the user is anonymous and has never bought anything, the Customer Center renders an empty / restore only state. If the app supports login, call `Purchases.logIn(userId)` before opening the Customer Center. +- Customer Center is separate from paywalls. The standard pattern: expose a "Manage subscription" row in the settings screen that opens the Customer Center. Paywalls are for new purchases; the Customer Center is for existing subscribers. +- The UI owns the flow. Restore, cancel, refund, and promotional offer flows run inside the component. Listen for the lifecycle callbacks (`onRestoreCompleted`, `onRefundRequestStarted`, `onManagementOptionSelected`, `onPromotionalOfferSucceeded`, etc.) to react in app code, not to drive the flow. +- Platform availability varies. iOS has had Customer Center longest; Android, KMP, Flutter, and React Native follow. Platform files flag any gaps. Refund requests are an iOS only action because only Apple exposes in app refund requests; on Android, the "Manage subscription" option links out to the Google Play subscriptions screen. +- If the installed SDK version is older than Customer Center support, the fallback is a manual subscription management screen: show the user's active entitlements from `Purchases.customerInfo()`, expose a `Purchases.restorePurchases()` button, and link to the store's subscription management URL. Point the user to upgrade the SDK if they want the full Customer Center. + +## 3. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file is self contained: install command, exact snippet to present the Customer Center, and the callback shape. + +## 4. Verify + +Do not claim the integration is complete until: + +1. The project builds on the target platform. +2. Sign into the app with a test user that has at least one active sandbox subscription. Trigger the code path that opens the Customer Center. The UI loads and the subscription appears in the list. +3. At least one configured action runs end to end. The simplest check: tap Restore purchases and confirm the restore completed callback fires with a non-empty `customerInfo.entitlements.active` map. If the dashboard has Cancel / Refund / Support actions configured, verify the corresponding callback (`onManagementOptionSelected`, `onRefundRequestStarted`, etc.) fires when the user taps through. +4. Dismissing the Customer Center fires the `onDismiss` callback. + +If the Customer Center opens but is empty, the signed in user has no purchases, or the dashboard Customer Center section is not configured. Fix in the dashboard, reload, and retry. diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/platforms/android.md b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/android.md new file mode 100644 index 00000000..d5c090d6 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/android.md @@ -0,0 +1,89 @@ +# revenuecat-customer-center: Android (native Kotlin) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +Add the `purchases-ui` artifact alongside `purchases`: + +```kotlin +// app/build.gradle.kts +dependencies { + implementation("com.revenuecat.purchases:purchases:") + implementation("com.revenuecat.purchases:purchases-ui:") +} +``` + +`purchases-ui` requires Jetpack Compose. If the app is not already on Compose, enable it in the module: + +```kotlin +android { + buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = "..." } +} +``` + +## Implement + +Two APIs: the `CustomerCenter` composable and the `ShowCustomerCenter` activity result contract. Pick one based on how your app is built. + +### Compose: embed `CustomerCenter` directly + +```kotlin +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenter + +@Composable +fun SubscriptionSettingsScreen(onDismiss: () -> Unit) { + CustomerCenter( + modifier = Modifier.fillMaxSize(), + onDismiss = onDismiss, + ) +} +``` + +`CustomerCenter(modifier, options, onDismiss)` also accepts a `CustomerCenterOptions` builder for configuring listener callbacks. The minimal form above is sufficient for most apps. + +### Activity result contract: launch from any `ComponentActivity` or `Fragment` + +```kotlin +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import com.revenuecat.purchases.ui.revenuecatui.customercenter.ShowCustomerCenter + +class SettingsActivity : ComponentActivity() { + + private val customerCenter: ActivityResultLauncher = + registerForActivityResult(ShowCustomerCenter()) { + // The Customer Center was dismissed. Refresh subscription state. + } + + private fun openCustomerCenter() { + customerCenter.launch(Unit) + } +} +``` + +`ShowCustomerCenter` starts the bundled `CustomerCenterActivity`, which hosts the composable full screen with Material 3 theming and a close button. + +## Notes + +- `CustomerCenter` and `ShowCustomerCenter` require `purchases-ui` 8.x or newer. If the installed version is older, fall back to rendering subscription state from `Purchases.sharedInstance.getCustomerInfo(...)` plus a restore button and a link to `https://play.google.com/store/account/subscriptions`. +- Refund requests are not supported on Android (refunds go through Google Play). Any refund related dashboard action degrades gracefully to either hiding the option or deep linking to the Play subscriptions page. +- When the user taps Manage subscription on Android, the Customer Center opens the system subscriptions screen via an `Intent`. Your app returns to the foreground afterwards. +- The composable requires a Material 3 theme in the Compose tree. `CustomerCenterActivity` sets one up for you; if you embed `CustomerCenter` inside your own Compose host, wrap it in `MaterialTheme { ... }`. +- Identify the user before opening. `Purchases.sharedInstance.logIn(appUserId, ...)` ensures the Customer Center loads the right customer's subscriptions. + +## Verify + +Sign into the app with a Google account that has at least one active sandbox subscription: + +1. Open the Customer Center via either the composable or `ShowCustomerCenter`. The subscription appears in the list, with the actions configured in the dashboard. +2. Tap Restore purchases. The active entitlements reload. If you wired `CustomerCenterOptions.listener`, its `onRestoreCompleted` callback fires. +3. Tap Manage subscription. The Google Play subscriptions screen opens in a new task. +4. Close the Customer Center. With the composable, `onDismiss` fires. With `ShowCustomerCenter`, the `ActivityResultCallback` runs. +5. `adb logcat -s Purchases` shows the customer info fetch and any transaction events. + +If the view is empty for a user who has purchases, confirm `Purchases.sharedInstance.appUserID` matches the dashboard user who owns those transactions and that the dashboard's Customer Center section is configured. diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/flutter.md new file mode 100644 index 00000000..49aaf30c --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/flutter.md @@ -0,0 +1,107 @@ +# revenuecat-customer-center: Flutter + +The Customer Center ships in `purchases_ui_flutter`, the same package that provides paywalls. + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +In `pubspec.yaml`: + +```yaml +dependencies: + purchases_flutter: ^ + purchases_ui_flutter: ^ +``` + +Then: + +```bash +flutter pub get +cd ios && pod install && cd .. +``` + +Minimum iOS deployment target is 15.0 (required by `RevenueCatUI`'s `CustomerCenterView`). Update `ios/Podfile`: + +```ruby +platform :ios, '15.0' +``` + +Android minimum SDK is 21. The Customer Center uses Jetpack Compose on Android under the hood. + +## Implement + +Two APIs: the imperative `RevenueCatUI.presentCustomerCenter(...)` method and the declarative `CustomerCenterView` widget. Prefer the imperative one for a "Manage subscription" button. Use the widget when you want to embed the Customer Center inside a Flutter route. + +### Imperative: `presentCustomerCenter` + +```dart +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; + +Future openCustomerCenter() async { + await RevenueCatUI.presentCustomerCenter( + onRestoreCompleted: (customerInfo) { + // refresh app state + }, + onShowingManageSubscriptions: () { + // user navigated into the manage-subscription flow + }, + onManagementOptionSelected: (optionId, url) { + // optionId is one of: 'cancel', 'custom_url', 'missing_purchase', 'refund_request', 'change_plans', ... + }, + onPromotionalOfferSucceeded: (customerInfo, transaction, offerId) { + // promo offer accepted + }, + ); +} +``` + +All callbacks are optional. Pass only the ones the app cares about. + +### Declarative: `CustomerCenterView` widget + +```dart +import 'package:flutter/material.dart'; +import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; + +class SubscriptionSettingsPage extends StatelessWidget { + const SubscriptionSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomerCenterView( + shouldShowCloseButton: false, // rely on the app bar back button + onDismiss: () => Navigator.of(context).pop(), + onRestoreCompleted: (customerInfo) { + // refresh app state + }, + onManagementOptionSelected: (optionId, url) { + // react to cancel / change plan / custom url + }, + ), + ); + } +} +``` + +## Notes + +- `purchases_ui_flutter` supports iOS and Android only. The Customer Center is unavailable on other Flutter targets. +- Refund requests are iOS only. On Android, `onRefundRequestStarted` / `onRefundRequestCompleted` never fire; the UI deep links to Google Play's subscriptions screen instead. +- `shouldShowCloseButton` only affects iOS. On Android, the Customer Center always shows a close button regardless of this flag. +- Log the user in before presenting. Call `Purchases.logIn(userId)` if your app has identified users; the Customer Center then loads that user's subscriptions. +- Do not call `Purchases.restorePurchases()` while the Customer Center is on screen. The Restore action inside the UI drives that flow. +- `CustomerCenterView` embeds a native platform view. Place it inside a constrained container (`Scaffold`, `SizedBox`, or `Expanded`). Do not give it zero-height constraints. + +## Verify + +Run on a device or simulator signed into a sandbox account that owns at least one active subscription: + +1. Trigger the Customer Center. The active subscription appears with the dashboard configured actions. +2. Tap Restore purchases. `onRestoreCompleted` fires with a `CustomerInfo` whose `entitlements.active` is non-empty. +3. Tap the manage action. On iOS, the system manage subscriptions sheet opens. On Android, Google Play's subscriptions screen opens in a new task. `onManagementOptionSelected` fires with the selected option id. +4. Close the Customer Center. `onDismiss` fires. + +If the view is empty for a user who has purchases, confirm `Purchases.appUserID` matches the user who owns them, and that the Customer Center is configured in the RevenueCat dashboard. diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/ios.md new file mode 100644 index 00000000..14f2cf65 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/ios.md @@ -0,0 +1,134 @@ +# revenuecat-customer-center: iOS (native) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +`CustomerCenterView` ships in the `RevenueCatUI` product of the `purchases-ios` package. If the app already has `RevenueCat`, add the `RevenueCatUI` library to the app target. + +### Swift Package Manager + +In Xcode: target -> General -> Frameworks, Libraries, and Embedded Content -> +, pick the `purchases-ios` package and add the `RevenueCatUI` library. + +For a `Package.swift`-based project: + +```swift +.target( + name: "MyApp", + dependencies: [ + .product(name: "RevenueCat", package: "purchases-ios"), + .product(name: "RevenueCatUI", package: "purchases-ios"), + ] +) +``` + +### CocoaPods + +```ruby +pod 'RevenueCat' +pod 'RevenueCatUI' +``` + +Then `pod install`. + +Minimum deployment target for `CustomerCenterView` is iOS 15.0. The Customer Center is iOS only: it is unavailable on macOS, tvOS, and watchOS. + +## Implement + +### SwiftUI: present as a `.sheet` + +```swift +import SwiftUI +import RevenueCat +import RevenueCatUI + +struct SettingsView: View { + @State private var isShowingCustomerCenter = false + + var body: some View { + List { + Button("Manage subscription") { + isShowingCustomerCenter = true + } + } + .sheet(isPresented: $isShowingCustomerCenter) { + CustomerCenterView() + .onCustomerCenterRestoreCompleted { customerInfo in + // refresh app state + } + .onCustomerCenterManagementOptionSelected { action in + // log which option the user picked + } + } + } +} +``` + +`CustomerCenterView()` with no arguments is the intended usage. It loads the current `Purchases.shared.customerInfo()` and renders the dashboard configured actions. + +### SwiftUI: full screen modal + +```swift +.fullScreenCover(isPresented: $isShowingCustomerCenter) { + NavigationStack { + CustomerCenterView() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { isShowingCustomerCenter = false } + } + } + } +} +``` + +### View modifiers for lifecycle events + +Attach modifiers to the `CustomerCenterView` to react to specific flows: + +- `.onCustomerCenterRestoreStarted { ... }` +- `.onCustomerCenterRestoreCompleted { customerInfo in ... }` +- `.onCustomerCenterRestoreFailed { error in ... }` +- `.onCustomerCenterShowingManageSubscriptions { ... }` +- `.onCustomerCenterRefundRequestStarted { productId in ... }` +- `.onCustomerCenterRefundRequestCompleted { productId, status in ... }` +- `.onCustomerCenterFeedbackSurveyCompleted { optionId in ... }` +- `.onCustomerCenterManagementOptionSelected { action in ... }` +- `.onCustomerCenterCustomActionSelected { actionId, purchaseId in ... }` +- `.onCustomerCenterPromotionalOfferSucceeded { ... }` + +All are optional. + +### UIKit + +A SwiftUI view hosted inside a `UIHostingController` presents the Customer Center from UIKit: + +```swift +import UIKit +import SwiftUI +import RevenueCatUI + +final class SettingsViewController: UIViewController { + @IBAction func openCustomerCenter() { + let host = UIHostingController(rootView: CustomerCenterView()) + present(host, animated: true) + } +} +``` + +## Notes + +- Customer Center is available on `iOS 15+` only. Guard the call site with `if #available(iOS 15, *)` if your deployment target is lower. +- The view is self contained. Do not call `Purchases.shared.restorePurchases()` from outside when it is on screen; the Restore flow inside the view owns that path. +- Refund request flows (Apple's `SKPaymentQueue.showRequestRefundController`) are triggered by the Customer Center only when the dashboard has the refund action enabled and the underlying transaction is eligible. +- Dashboard configuration lives under Customer Center in the RevenueCat app. Without it, the view shows an empty state. + +## Verify + +Sign into the app with an Apple ID that has at least one active sandbox subscription: + +1. Present the Customer Center. The active subscription appears in the list, with the actions configured in the dashboard. +2. Tap Restore purchases. `.onCustomerCenterRestoreCompleted` fires with a `CustomerInfo` whose `entitlements.active` is non-empty. +3. Tap the manage / cancel action. `.onCustomerCenterManagementOptionSelected` fires with the chosen action. For Apple hosted cancel, the system subscription management sheet opens. +4. Dismiss the sheet. The `isPresented` binding flips back to `false`. + +If the view is empty for a test user who has purchases, verify they are signed into the correct RevenueCat `appUserID` via `Purchases.shared.logIn(...)` and that the dashboard's Customer Center section is configured. diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/kmp.md new file mode 100644 index 00000000..54d92f99 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/kmp.md @@ -0,0 +1,67 @@ +# revenuecat-customer-center: Kotlin Multiplatform + +`purchases-kmp-ui` exposes a Compose Multiplatform `CustomerCenter` composable that wraps the native Customer Center on each target (SwiftUI `CustomerCenterView` on iOS, Compose `CustomerCenter` on Android). + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. The KMP tag uses a `+` format (e.g. `2.10.2+17.55.1`), where the part before `+` is the KMP wrapper version and the part after is the bundled `purchases-hybrid-common` version. Use the full tag string as the artifact version first; if Gradle interprets the `+` as a wildcard, fall back to the wrapper portion only (e.g. `2.10.2`). If GitHub is unreachable, ask the user for a version to pin. + +In the shared module's `build.gradle.kts`: + +```kotlin +kotlin { + // your targets: androidTarget(), iosX64(), iosArm64(), iosSimulatorArm64(), etc. + + sourceSets { + commonMain.dependencies { + implementation("com.revenuecat.purchases:purchases-kmp-core:") + implementation("com.revenuecat.purchases:purchases-kmp-ui:") + } + } +} +``` + +Compose Multiplatform must already be set up on the shared module. On iOS, the UI artifact bridges to `RevenueCatUI`; for CocoaPods-based KMP projects, the Kotlin CocoaPods plugin wires the pod automatically. For pure SPM setups, follow the iOS linking section of the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp#readme). + +## Implement + +Shared composable: + +```kotlin +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.revenuecat.purchases.kmp.ui.revenuecatui.CustomerCenter + +@Composable +fun SubscriptionSettingsScreen(onDismiss: () -> Unit) { + CustomerCenter( + modifier = Modifier.fillMaxSize(), + onDismiss = onDismiss, + ) +} +``` + +The common API is minimal: `modifier` and a required `onDismiss` callback. Platform specific Customer Center callbacks (restore, refund, management option, etc.) surface through the native SDKs; if you need them on common, check the installed `purchases-kmp-ui` version for an `options` overload. If the overload is not present in your version, host the composable behind a platform specific wrapper and listen to events using the native SDK's view modifiers (iOS) or `CustomerCenterOptions.listener` (Android). + +### Presenting from platform UI + +- Android: place `CustomerCenter(...)` inside any Compose screen. It fills the provided modifier constraints. +- iOS: wrap `CustomerCenter(...)` in a Compose Multiplatform `UIViewController` via `ComposeUIViewController { CustomerCenter(...) }`, then present that from SwiftUI with `.sheet` or `.fullScreenCover`, or from UIKit via `present(_:animated:)`. + +## Notes + +- Customer Center requires iOS 15+ on the iOS target. On Android, refund flows degrade to deep linking into Google Play (Apple only feature). +- The KMP common API exposes fewer hooks than either native SDK. If your product requires `onRestoreCompleted`, `onManagementOptionSelected`, or other lifecycle callbacks, wire them through platform code directly, or upgrade to a `purchases-kmp-ui` release that exposes them in common. +- If the IDE cannot resolve `com.revenuecat.purchases:purchases-kmp-ui`, confirm artifact coordinates against the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp) for your installed version; the module has evolved across 1.x. +- Ensure `Purchases.logIn(appUserId)` has run for signed in users before opening the Customer Center, or subscriptions will show up under an anonymous ID. + +## Verify + +Run each platform target with a sandbox account that owns at least one active subscription: + +1. Present the shared `CustomerCenter` composable on iOS (via a Compose UIViewController host) and on Android (directly in Compose). The subscription and dashboard configured actions render on both platforms. +2. Tap Restore purchases on each platform. The purchase restores and the native SDK logs the restore event. +3. Tap the manage / cancel action. On iOS, the system manage subscriptions sheet opens. On Android, the Google Play subscriptions page opens in a new task. +4. Close the view. `onDismiss` fires on both platforms. +5. After restore, call `Purchases.sharedInstance.customerInfo()` from common code and confirm `entitlements.active["premium"]` exists. diff --git a/plugins/revenuecat/skills/revenuecat-customer-center/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/react-native.md new file mode 100644 index 00000000..c9b0700c --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-customer-center/platforms/react-native.md @@ -0,0 +1,101 @@ +# revenuecat-customer-center: React Native + +The Customer Center ships in `react-native-purchases-ui`, the same package that provides paywalls. + +## Install + +The npm install commands below resolve the current latest at install time, so no version pin is needed in this skill. To verify the installed version after install, check `package.json`. The full release history lives at . + +### Bare React Native + +```bash +npm install react-native-purchases-ui +cd ios && pod install && cd .. +``` + +### Expo + +```bash +npx expo install react-native-purchases-ui +``` + +`react-native-purchases-ui` links native code. It will not work in Expo Go. Use a development build: `npx expo prebuild` (bare) or `eas build --profile development`. + +Deployment targets: iOS 15+, Android minSdk 24 (Compose requirement on the native side). + +## Implement + +Two APIs: the imperative `RevenueCatUI.presentCustomerCenter(...)` method and the declarative `` component. Prefer the imperative one for a "Manage subscription" button. Use the component when you want to embed the Customer Center inside a React Native screen. + +### Imperative: `presentCustomerCenter` + +```tsx +import RevenueCatUI from 'react-native-purchases-ui'; + +async function openCustomerCenter() { + await RevenueCatUI.presentCustomerCenter({ + callbacks: { + onRestoreCompleted: ({ customerInfo }) => { + // refresh app state + }, + onShowingManageSubscriptions: () => { + // user navigated into the manage-subscription flow + }, + onManagementOptionSelected: (event) => { + // event.option is 'cancel' | 'custom_url' | 'missing_purchase' | 'refund_request' | 'change_plans' | ... + // event.url is the custom URL for 'custom_url', null otherwise + }, + onPromotionalOfferSucceeded: ({ customerInfo, transaction, offerId }) => { + // promo offer accepted + }, + }, + }); +} +``` + +All callbacks are optional. The promise resolves when the Customer Center is dismissed. + +### Declarative: `` component + +```tsx +import { View } from 'react-native'; +import RevenueCatUI from 'react-native-purchases-ui'; + +export function SubscriptionSettingsScreen({ navigation }) { + return ( + + navigation.goBack()} + onRestoreCompleted={({ customerInfo }) => { + // refresh app state + }} + onManagementOptionSelected={(event) => { + // react to cancel / change_plans / custom_url + }} + /> + + ); +} +``` + +## Notes + +- `react-native-purchases-ui` is iOS + Android only. There is no web or desktop target. +- Refund requests are iOS only. `onRefundRequestStarted` / `onRefundRequestCompleted` never fire on Android; the UI deep links into Google Play's subscriptions screen instead. +- `shouldShowCloseButton` only affects iOS. Android always shows a close button regardless of this prop. +- `` is a native view host. Wrap it in `` or give it an explicit height. Zero-size constraints render a blank view. +- Log the user in before opening. If your app has identified users, call `Purchases.logIn(userId)` first; the Customer Center loads that user's subscriptions. +- Do not call `Purchases.restorePurchases()` while the Customer Center is on screen. The UI runs restore itself. + +## Verify + +Run the app on a device or simulator signed into a sandbox account with at least one active subscription: + +1. Open the Customer Center. The subscription appears in the list with the dashboard configured actions. +2. Tap Restore purchases. `onRestoreCompleted` fires with a `customerInfo` whose `entitlements.active` is non-empty. +3. Tap the manage action. On iOS, the system manage subscriptions sheet opens. On Android, Google Play's subscriptions screen opens in a new task. `onManagementOptionSelected` fires with the selected option. +4. Close the Customer Center. The imperative promise resolves, or the component's `onDismiss` fires. +5. After restore, call `Purchases.getCustomerInfo()` from JS and confirm `customerInfo.entitlements.active['premium']` is defined. + +If the view is empty, confirm the signed in user matches the dashboard `appUserID` that owns the transactions and that the Customer Center is configured in the RevenueCat dashboard. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/SKILL.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/SKILL.md new file mode 100644 index 00000000..3045e305 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/SKILL.md @@ -0,0 +1,48 @@ +--- +name: revenuecat-entitlements-gate +description: "Check whether a RevenueCat user currently has access to a paid feature via entitlements. Use when the user asks to gate a feature behind premium, check if the user has a pro subscription, read customerInfo active entitlements, show or hide a feature based on subscription status, react to entitlement changes, or 'is the user subscribed' on iOS, Android, Kotlin Multiplatform, Flutter, or React Native." +--- + +# revenuecat-entitlements-gate: check a RevenueCat entitlement + +Use this skill when the user wants to decide whether to show or hide a feature based on an active RevenueCat entitlement. The skill covers the one shot check and the reactive listener; it does not cover purchasing (see `revenuecat-purchase-flow`) or auth (`revenuecat-identify-user`). + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency -> read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root -> read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*` -> read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP) -> read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root -> read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Shared concepts (all platforms) + +- Check the entitlement identifier, not the product ID. The identifier (for example `"premium"`) is configured in the RevenueCat dashboard and mapped to one or more products. Using the entitlement lets you change products, prices, and stores without touching app code. +- `customerInfo.entitlements.active` is the source of truth. It is a `Map` keyed by entitlement identifier. Presence in `active` means the user currently has access. Absence means they do not, regardless of past purchases. +- Do not gate on purchase history. Expired subscriptions still appear in `customerInfo.entitlements.all` but drop out of `active`. Use `active` only. +- Fetch once, then subscribe. The first `customerInfo` call returns a cached value quickly, and the SDK refreshes in the background. Every SDK exposes a listener or stream that fires when entitlements change (after a purchase, restore, renewal, or expiration). Subscribe to that instead of polling. +- The SDK must be configured first. If `Purchases.configure(...)` has not run, the entitlement call will fail. Set up the SDK via `integrate-revenuecat` before using this skill. + +## 3. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file shows the one shot check, the reactive subscription, and where to place each in a typical app. + +## 4. Verify + +Do not claim the gate works until: + +1. A user with an active entitlement sees the gated feature, and a user without it does not. +2. When the entitlement state changes (test with a sandbox purchase or a manual grant in the dashboard), the UI updates without a manual restart, confirming the listener is wired. +3. The entitlement identifier in the code matches an identifier that exists in the RevenueCat dashboard. A typo here silently gates everyone out. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/android.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/android.md new file mode 100644 index 00000000..0c8f18bd --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/android.md @@ -0,0 +1,91 @@ +# revenuecat-entitlements-gate: Android (native Kotlin) + +## One shot check (coroutines) + +Use the `awaitCustomerInfo()` suspend extension. It throws a `PurchasesException` on failure. + +```kotlin +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.awaitCustomerInfo + +suspend fun hasPremium(): Boolean = try { + val info = Purchases.sharedInstance.awaitCustomerInfo() + info.entitlements.active["premium"] != null +} catch (e: PurchasesException) { + // Network or auth error. Treat as "no access" and log for diagnostics. + false +} +``` + +`info.entitlements["premium"]?.isActive == true` is the equivalent check against the full map. + +## One shot check (callback, Java friendly) + +```kotlin +Purchases.sharedInstance.getCustomerInfo(object : ReceiveCustomerInfoCallback { + override fun onReceived(customerInfo: CustomerInfo) { + val hasPremium = customerInfo.entitlements.active["premium"] != null + // update UI + } + override fun onError(error: PurchasesError) { + // treat as no access + } +}) +``` + +## Reactive subscription + +`Purchases.sharedInstance.updatedCustomerInfoListener` is a single listener property. Assign a lambda early (for example in the custom `Application` or a DI scoped singleton) so every screen can observe the same state. + +```kotlin +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +object EntitlementsRepository { + private val _hasPremium = MutableStateFlow(false) + val hasPremium = _hasPremium.asStateFlow() + + fun start() { + Purchases.sharedInstance.updatedCustomerInfoListener = + UpdatedCustomerInfoListener { info -> + _hasPremium.value = info.entitlements.active["premium"] != null + } + } +} +``` + +Seed the flow once at startup so the first emission does not wait for a customer info update: + +```kotlin +// In a coroutine scope tied to app lifetime. +runCatching { + val info = Purchases.sharedInstance.awaitCustomerInfo() + EntitlementsRepository._hasPremium.value = + info.entitlements.active["premium"] != null +} +``` + +## Compose usage + +```kotlin +@Composable +fun RootScreen() { + val hasPremium by EntitlementsRepository.hasPremium.collectAsState() + if (hasPremium) PremiumScreen() else PaywallScreen() +} +``` + +## Notes + +- `updatedCustomerInfoListener` holds a single reference. Setting it again replaces the previous listener, so centralize ownership in a repository or the `Application`. +- Replace `"premium"` with the entitlement identifier configured in the RevenueCat dashboard. It is case sensitive. +- `awaitCustomerInfo()` is declared in `com.revenuecat.purchases.awaitCustomerInfo`. If the import is missing, add the latest `com.revenuecat.purchases:purchases` dependency (see ) and re-sync. + +## Verify + +1. A sandbox user with the entitlement renders `PremiumScreen`; a fresh user renders `PaywallScreen`. +2. Make a sandbox purchase. The listener fires, the state flow updates, and Compose recomposes without restarting the app. +3. Check logcat for `Purchases` logs. An error fetching customer info on launch usually means the SDK was not configured, or the API key is wrong. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/flutter.md new file mode 100644 index 00000000..59aad03f --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/flutter.md @@ -0,0 +1,86 @@ +# revenuecat-entitlements-gate: Flutter + +## One shot check + +```dart +import 'package:purchases_flutter/purchases_flutter.dart'; + +Future hasPremium() async { + try { + final info = await Purchases.getCustomerInfo(); + return info.entitlements.active.containsKey('premium'); + } catch (e) { + // Network or auth error. Treat as "no access" and log for diagnostics. + return false; + } +} +``` + +`info.entitlements.all['premium']?.isActive == true` is equivalent, but `active` is usually what you want. + +## Reactive subscription + +`Purchases.addCustomerInfoUpdateListener` registers a callback that fires on every entitlement change. Feed it into a `ChangeNotifier`, `StreamController`, or your state management library. + +```dart +import 'package:flutter/foundation.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +class EntitlementsModel extends ChangeNotifier { + bool _hasPremium = false; + bool get hasPremium => _hasPremium; + + late final CustomerInfoUpdateListener _listener; + + EntitlementsModel() { + _listener = (info) { + final next = info.entitlements.active.containsKey('premium'); + if (next != _hasPremium) { + _hasPremium = next; + notifyListeners(); + } + }; + Purchases.addCustomerInfoUpdateListener(_listener); + _seed(); + } + + Future _seed() async { + try { + final info = await Purchases.getCustomerInfo(); + _listener(info); + } catch (_) {/* ignore; listener will fire on next update */} + } + + @override + void dispose() { + Purchases.removeCustomerInfoUpdateListener(_listener); + super.dispose(); + } +} +``` + +## Widget usage + +```dart +ChangeNotifierProvider( + create: (_) => EntitlementsModel(), + child: Consumer( + builder: (_, model, __) => + model.hasPremium ? const PremiumScreen() : const PaywallScreen(), + ), +); +``` + +For a one off check with less ceremony, `FutureBuilder(future: Purchases.getCustomerInfo(), ...)` works, but it will not react to purchase events. + +## Notes + +- Always call `removeCustomerInfoUpdateListener` when the owning object is disposed. Registered listeners leak otherwise. +- Replace `'premium'` with the entitlement identifier configured in the RevenueCat dashboard. It is case sensitive. +- `purchases_flutter` targets iOS and Android only. On other platforms the calls throw; guard with `Platform.isIOS || Platform.isAndroid` if your app has additional targets. + +## Verify + +1. A sandbox user with the entitlement renders `PremiumScreen`; a fresh user renders `PaywallScreen`. +2. Make a sandbox purchase. The listener fires, `notifyListeners()` runs, and the widget tree rebuilds without a hot restart. +3. Watch the native logs (`flutter logs` / Xcode console) for `Purchases` entries. A repeated auth error on launch means the API key is wrong or the SDK was never configured. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/ios.md new file mode 100644 index 00000000..0ab25466 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/ios.md @@ -0,0 +1,84 @@ +# revenuecat-entitlements-gate: iOS (native) + +## One shot check + +Use `Purchases.shared.customerInfo()` from an async context. It returns a cached value on the first call and refreshes in the background. + +```swift +import RevenueCat + +func hasPremium() async -> Bool { + do { + let info = try await Purchases.shared.customerInfo() + return info.entitlements["premium"]?.isActive == true + } catch { + // Network or auth error. Treat as "no access" and log for diagnostics. + print("RevenueCat customerInfo failed: \(error)") + return false + } +} +``` + +`info.entitlements.active["premium"] != nil` is equivalent and slightly shorter. Either form is fine. + +## Reactive subscription (SwiftUI) + +`Purchases.shared.customerInfoStream` is an `AsyncStream` that emits the current value and every subsequent update. + +```swift +import SwiftUI +import RevenueCat + +@MainActor +final class EntitlementsModel: ObservableObject { + @Published var hasPremium = false + + func observe() async { + for await info in Purchases.shared.customerInfoStream { + hasPremium = info.entitlements["premium"]?.isActive == true + } + } +} + +struct RootView: View { + @StateObject private var model = EntitlementsModel() + + var body: some View { + Group { + if model.hasPremium { + PremiumView() + } else { + PaywallView() + } + } + .task { await model.observe() } + } +} +``` + +The `.task` modifier starts the stream when the view appears and cancels it on disappear. No manual teardown is needed. + +## UIKit alternative + +For UIKit, call `customerInfo(completion:)` on screens that need a fresh value, and keep a long lived `Task` observing `customerInfoStream` on a singleton or scene delegate. + +```swift +Task { + for await info in Purchases.shared.customerInfoStream { + let isPremium = info.entitlements["premium"]?.isActive == true + await MainActor.run { /* update UI / notify observers */ } + } +} +``` + +## Notes + +- `customerInfo()` requires iOS 13+. For older targets, use `customerInfo(completion:)`. +- Replace `"premium"` with the entitlement identifier configured in the RevenueCat dashboard. It is case sensitive. +- Do not call `customerInfo()` in a tight loop. One initial fetch plus the stream is sufficient for the lifetime of the app. + +## Verify + +1. A sandbox user with the entitlement renders `PremiumView`; a fresh user renders `PaywallView`. +2. Make a sandbox purchase. The stream fires and the UI swaps without relaunching. +3. Revoke the entitlement in the dashboard (or let the sandbox subscription expire). Within a few minutes, or on next app foreground, the stream emits the downgrade. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/kmp.md new file mode 100644 index 00000000..cb95cdb1 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/kmp.md @@ -0,0 +1,95 @@ +# revenuecat-entitlements-gate: Kotlin Multiplatform + +`purchases-kmp` wraps the native iOS and Android SDKs. The commonMain API looks the same on both sides; only the initial configuration differs (see `integrate-revenuecat`). + +## One shot check (coroutines, commonMain) + +Use the `awaitCustomerInfo` suspend extension from `com.revenuecat.purchases.kmp.ktx`. It throws a `PurchasesException` on failure. + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitCustomerInfo +import com.revenuecat.purchases.kmp.models.PurchasesException + +suspend fun hasPremium(): Boolean = try { + val info = Purchases.sharedInstance.awaitCustomerInfo() + info.entitlements.active["premium"] != null +} catch (e: PurchasesException) { + // Network or auth error. Treat as "no access" and log for diagnostics. + false +} +``` + +## One shot check (callbacks) + +If you do not want the coroutine dependency: + +```kotlin +Purchases.sharedInstance.getCustomerInfo( + onError = { /* treat as no access */ }, + onSuccess = { info -> + val hasPremium = info.entitlements.active["premium"] != null + // publish to your state holder + } +) +``` + +## Reactive subscription + +`purchases-kmp` exposes a single `PurchasesDelegate`. Implement `onCustomerInfoUpdated` and publish the current entitlement state into a `MutableStateFlow` that your UI observes. + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.PurchasesDelegate +import com.revenuecat.purchases.kmp.models.CustomerInfo +import com.revenuecat.purchases.kmp.models.StoreProduct +import com.revenuecat.purchases.kmp.models.StoreTransaction +import com.revenuecat.purchases.kmp.models.PurchasesError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +object EntitlementsRepository : PurchasesDelegate { + private val _hasPremium = MutableStateFlow(false) + val hasPremium = _hasPremium.asStateFlow() + + fun start() { + Purchases.sharedInstance.delegate = this + } + + override fun onCustomerInfoUpdated(customerInfo: CustomerInfo) { + _hasPremium.value = customerInfo.entitlements.active["premium"] != null + } + + override fun onPurchasePromoProduct( + product: StoreProduct, + startPurchase: ( + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit + ) -> Unit + ) { + // Ignore App Store promoted purchases here, or forward to your purchase flow. + } +} +``` + +Seed the flow once at startup so the first emission does not wait for an update: + +```kotlin +// In a coroutine scope tied to app lifetime. +runCatching { + val info = Purchases.sharedInstance.awaitCustomerInfo() + EntitlementsRepository.onCustomerInfoUpdated(info) +} +``` + +## Notes + +- `Purchases.sharedInstance.delegate` holds a single reference. If you need to fan out to multiple consumers, funnel through a single repository (as shown) and expose a `StateFlow`. +- Replace `"premium"` with the entitlement identifier configured in the RevenueCat dashboard. It is case sensitive. +- If your installed version of `purchases-kmp` does not expose `awaitCustomerInfo` in `com.revenuecat.purchases.kmp.ktx`, prefer what the IDE autocompletes and see the `purchases-kmp` README. Some versions ship result based variants in a separate module. + +## Verify + +1. A sandbox user with the entitlement renders the premium UI; a fresh user sees the paywall. +2. Make a sandbox purchase on each target. The delegate's `onCustomerInfoUpdated` fires and the state flow updates without relaunching. +3. On each platform, check the native logs (Xcode console on iOS, logcat on Android) for `Purchases` entries. The KMP SDK is a thin wrapper and its logs come from the native SDKs. diff --git a/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/react-native.md new file mode 100644 index 00000000..10a3fafd --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-entitlements-gate/platforms/react-native.md @@ -0,0 +1,74 @@ +# revenuecat-entitlements-gate: React Native + +## One shot check + +```ts +import Purchases from 'react-native-purchases'; + +export async function hasPremium(): Promise { + try { + const info = await Purchases.getCustomerInfo(); + return info.entitlements.active['premium'] !== undefined; + } catch (e) { + // Network or auth error. Treat as "no access" and log for diagnostics. + console.warn('RevenueCat getCustomerInfo failed', e); + return false; + } +} +``` + +## Reactive hook + +`Purchases.addCustomerInfoUpdateListener` registers a callback that fires on every entitlement change. Wrap it in a hook so components can subscribe and clean up automatically. + +```tsx +import { useEffect, useState } from 'react'; +import Purchases, { CustomerInfo } from 'react-native-purchases'; + +export function useHasPremium(entitlementId = 'premium'): boolean { + const [hasPremium, setHasPremium] = useState(false); + + useEffect(() => { + let cancelled = false; + + const apply = (info: CustomerInfo) => { + if (cancelled) return; + setHasPremium(info.entitlements.active[entitlementId] !== undefined); + }; + + const listener = (info: CustomerInfo) => apply(info); + Purchases.addCustomerInfoUpdateListener(listener); + + // Seed initial value. + Purchases.getCustomerInfo().then(apply).catch(() => {/* ignore */}); + + return () => { + cancelled = true; + Purchases.removeCustomerInfoUpdateListener(listener); + }; + }, [entitlementId]); + + return hasPremium; +} +``` + +## Component usage + +```tsx +function RootScreen() { + const hasPremium = useHasPremium(); + return hasPremium ? : ; +} +``` + +## Notes + +- Always call `removeCustomerInfoUpdateListener` on unmount. Listeners registered without cleanup accumulate across navigation. +- Replace `'premium'` with the entitlement identifier configured in the RevenueCat dashboard. It is case sensitive. +- Under Expo, `react-native-purchases` requires a development build. Entitlement calls throw on Expo Go. Verify with `npx expo start --dev-client`. + +## Verify + +1. A sandbox user with the entitlement renders ``; a fresh user renders ``. +2. Make a sandbox purchase. The listener fires, state updates, and the component re-renders without reloading the bundle. +3. On iOS check the Xcode console, on Android check `adb logcat` filtered by `Purchases`. Metro's JS console will not show native SDK logs. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/SKILL.md b/plugins/revenuecat/skills/revenuecat-identify-user/SKILL.md new file mode 100644 index 00000000..db55196a --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/SKILL.md @@ -0,0 +1,51 @@ +--- +name: revenuecat-identify-user +description: Tie RevenueCat identity to your app's auth system. Use when the user asks to log in to RevenueCat, sync a user with RevenueCat, switch RevenueCat user on login, log out of RevenueCat, move a user from anonymous to identified, set appUserID, or handle account switching on iOS, Android, Kotlin Multiplatform, Flutter, or React Native. +--- + +# revenuecat-identify-user: connect RevenueCat to your auth system + +Use this skill when the user wants to call `logIn` / `logOut` on the RevenueCat SDK so that their app users line up with RevenueCat subscribers. This skill does not cover initial SDK setup (see `integrate-revenuecat`), purchases (`revenuecat-purchase-flow`), or gating (`revenuecat-entitlements-gate`). + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency -> read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root -> read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*` -> read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP) -> read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root -> read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Shared concepts (all platforms) + +- Anonymous by default. Before `logIn` is called, RevenueCat assigns a stable anonymous ID prefixed `$RCAnonymousID:`. Purchases made while anonymous are aliased onto the real `appUserID` the first time `logIn` is called with it, so there is no "lost purchase" risk from letting users buy before signing in. +- Never use email, phone number, or a sequential database id as the appUserID. Use a stable opaque value such as your backend's user UUID, or a hash of the user id. RevenueCat treats the ID as an opaque string and it is difficult to change later. +- Call `logIn` after your auth system confirms the session. Do not call `logIn` speculatively. The typical trigger is your auth state listener firing with a signed in user. `logIn` returns both the user's current `CustomerInfo` and a `created: Boolean` that tells you whether this is a brand new RevenueCat customer. +- `logOut` only works on identified users. Calling `logOut` while the SDK is on an anonymous ID throws an error in every SDK (`PurchasesErrorCode.LogOutWithAnonymousUserError` or the iOS equivalent). Gate it behind your own "is signed in" flag. +- Restore is not login. `restorePurchases()` asks the store for the current receipt and attaches it to the current RevenueCat user. It does not switch identities. If the user signs in on a new device, call `logIn(appUserID)` first, then `restorePurchases()` only if they also expect to pull a receipt from the current store account. +- Account switching is `logOut` then `logIn`. If your app lets a user sign out and sign back in as someone else, call `logOut()` first, wait for it, then `logIn(newId)`. Do not try to swap directly with a second `logIn`, since that will alias the two IDs together. +- Configure first. `Purchases.configure(...)` must have run before `logIn` / `logOut`. If it has not, the SDK throws. + +## 3. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file shows the `logIn` and `logOut` calls wired into a typical auth state observer. + +## 4. Verify + +Do not claim identity sync works until: + +1. In the RevenueCat dashboard, the Customer page for your test user shows the same appUserID your backend uses, not the `$RCAnonymousID:` placeholder. +2. Signing out clears the ID back to a fresh anonymous user; signing in as a different account switches to that account's purchases (or shows none if it is a new account). +3. A purchase made while anonymous, followed by `logIn`, remains attached to the signed in user (aliased, not lost). +4. Calling `logOut` while already anonymous is handled, not treated as a crash or a silent success. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/platforms/android.md b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/android.md new file mode 100644 index 00000000..f3679616 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/android.md @@ -0,0 +1,100 @@ +# revenuecat-identify-user: Android (native Kotlin) + +## Log in + +Use the `awaitLogIn` coroutine extension. It returns a `LogInResult(customerInfo, created)` and throws `PurchasesException` on failure. + +```kotlin +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.awaitLogIn + +suspend fun syncRevenueCat(appUserID: String) { + try { + val result = Purchases.sharedInstance.awaitLogIn(appUserID) + // result.customerInfo is the current entitlement state for this user. + // result.created is true the first time this appUserID reaches RevenueCat. + } catch (e: PurchasesException) { + // Log and surface to your error pipeline; do not block the sign-in flow. + } +} +``` + +## Log out + +`awaitLogOut` throws `PurchasesException` with `PurchasesErrorCode.LogOutWithAnonymousUserError` if the current user is already anonymous. Gate on `Purchases.sharedInstance.isAnonymous` to avoid it. + +```kotlin +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.awaitLogOut + +suspend fun signOutRevenueCat() { + if (Purchases.sharedInstance.isAnonymous) return + try { + Purchases.sharedInstance.awaitLogOut() + } catch (e: PurchasesException) { + // Log; usually safe to ignore. The user is signing out anyway. + } +} +``` + +## Wire it to your auth listener + +Trigger the calls from wherever your app observes auth state. A typical pattern with a `StateFlow` of the current user id: + +```kotlin +class AuthObserver( + private val scope: CoroutineScope, + private val currentUserID: StateFlow, +) { + fun start() { + var previous: String? = null + scope.launch { + currentUserID.collect { next -> + when { + previous == null && next != null -> + syncRevenueCat(next) + previous != null && next == null -> + signOutRevenueCat() + previous != null && next != null && previous != next -> { + signOutRevenueCat() + syncRevenueCat(next) + } + } + previous = next + } + } + } +} +``` + +## Callback variants (Java friendly) + +```kotlin +Purchases.sharedInstance.logIn( + appUserID, + object : LogInCallback { + override fun onReceived(customerInfo: CustomerInfo, created: Boolean) { /* ... */ } + override fun onError(error: PurchasesError) { /* ... */ } + } +) + +Purchases.sharedInstance.logOut(object : ReceiveCustomerInfoCallback { + override fun onReceived(customerInfo: CustomerInfo) { /* ... */ } + override fun onError(error: PurchasesError) { /* ... */ } +}) +``` + +## Notes + +- Use a stable opaque identifier (UUID / hash) as the appUserID. Do not pass an email address, phone number, or a raw integer database id. +- `awaitLogIn` / `awaitLogOut` are extension suspend functions on `Purchases` in the package `com.revenuecat.purchases`. If your installed version exposes them in a different module, the IDE will autocomplete the correct import. +- Any anonymous purchases made before `awaitLogIn` are aliased onto the identified user automatically on that first login. + +## Verify + +1. Sign in your test user. The RevenueCat dashboard Customer page shows the appUserID you passed, not `$RCAnonymousID:...`. +2. Sign out. `Purchases.sharedInstance.isAnonymous` becomes `true`. +3. Sign in as a different user. Their entitlement state appears and the previous user's does not. +4. `logcat` filtered by `Purchases` shows the logIn / logOut lifecycle without errors. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/flutter.md new file mode 100644 index 00000000..7777f3ad --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/flutter.md @@ -0,0 +1,76 @@ +# revenuecat-identify-user: Flutter + +## Log in + +`Purchases.logIn(appUserID)` resolves to `LogInResult(customerInfo, created)`. + +```dart +import 'package:flutter/services.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +Future syncRevenueCat(String appUserID) async { + try { + final result = await Purchases.logIn(appUserID); + // result.customerInfo is the current entitlement state for this user. + // result.created is true the first time this appUserID reaches RevenueCat. + } on PlatformException catch (e) { + // Log and surface to your error pipeline; do not block the sign-in flow. + } +} +``` + +## Log out + +Calling `logOut` while the SDK is anonymous rejects with `PurchasesErrorCode.logOutWithAnonymousUserError`. Guard with `Purchases.isAnonymous` (available on the current customer info). + +```dart +Future signOutRevenueCat() async { + final info = await Purchases.getCustomerInfo(); + // Anonymous IDs always start with $RCAnonymousID: + if (info.originalAppUserId.startsWith(r'$RCAnonymousID:')) return; + + try { + await Purchases.logOut(); + } on PlatformException { + // Log; usually safe to ignore during sign-out. + } +} +``` + +## Wire it to your auth listener + +Drive the calls from your auth stream. Example with a `Stream` of the current user id: + +```dart +class RevenueCatIdentitySync { + String? _previous; + + void attach(Stream currentUserIDStream) { + currentUserIDStream.listen((next) async { + final prev = _previous; + _previous = next; + if (prev == null && next != null) { + await syncRevenueCat(next); + } else if (prev != null && next == null) { + await signOutRevenueCat(); + } else if (prev != null && next != null && prev != next) { + await signOutRevenueCat(); + await syncRevenueCat(next); + } + }); + } +} +``` + +## Notes + +- Use a stable opaque identifier (UUID / hash). Do not pass an email address, phone number, or a raw integer database id. +- `PurchasesErrorHelper.getErrorCode(e)` turns a `PlatformException` into a `PurchasesErrorCode` enum if you want to handle specific cases. For `logIn` / `logOut`, treating any error as "log and continue" is usually enough. +- Any purchase made anonymously before `Purchases.logIn` is aliased onto the identified user automatically on the first login with that id. + +## Verify + +1. Sign in your test user. The RevenueCat dashboard Customer page shows the appUserID you passed, not `$RCAnonymousID:...`. +2. Sign out. A subsequent `Purchases.getCustomerInfo()` returns an `originalAppUserId` starting with `$RCAnonymousID:`. +3. Sign in as a different user. Their entitlement state appears and the previous user's does not. +4. `flutter logs` shows the `Purchases` native logs for logIn / logOut without errors. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/ios.md new file mode 100644 index 00000000..5f6cb030 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/ios.md @@ -0,0 +1,79 @@ +# revenuecat-identify-user: iOS (native) + +## Log in + +`Purchases.shared.logIn(_:)` returns a tuple of `(customerInfo: CustomerInfo, created: Bool)`. Call it from your auth state observer once you have a confirmed user id. + +```swift +import RevenueCat + +func syncRevenueCat(with appUserID: String) async { + do { + let result = try await Purchases.shared.logIn(appUserID) + // result.customerInfo is the current entitlement state for this user. + // result.created is true the first time this appUserID reaches RevenueCat. + if result.created { + // Optional: set initial attributes on the new RC customer. + } + } catch { + print("RevenueCat logIn failed: \(error)") + } +} +``` + +## Log out + +`logOut()` throws if the current user is already anonymous. Guard it on your own signed in flag (or check `Purchases.shared.isAnonymous`). + +```swift +func signOutRevenueCat() async { + guard !Purchases.shared.isAnonymous else { return } + do { + _ = try await Purchases.shared.logOut() + } catch { + print("RevenueCat logOut failed: \(error)") + } +} +``` + +## Wire it to your auth listener + +The cleanest place is wherever your app receives auth state changes. Example with an `@Observable` auth model: + +```swift +@Observable +final class AuthStore { + private(set) var currentUserID: String? + + func onAuthStateChanged(newUserID: String?) async { + let previous = currentUserID + currentUserID = newUserID + + switch (previous, newUserID) { + case (nil, let newID?): // signed in + await syncRevenueCat(with: newID) + case (let oldID?, let newID?) where oldID != newID: // switched account + await signOutRevenueCat() + await syncRevenueCat(with: newID) + case (_?, nil): // signed out + await signOutRevenueCat() + default: + break + } + } +} +``` + +## Notes + +- `logIn` uses async/await on iOS 13+. The completion variant is `logIn(_:completion:)` with signature `(CustomerInfo?, Bool, PublicError?) -> Void`. +- RevenueCat will alias purchases that were made while anonymous onto the logged in `appUserID` automatically on the first `logIn` call with that id. +- Use a stable opaque identifier (UUID / hash) as the appUserID. Do not pass an email address or a raw integer database id. +- If the SDK was configured with an `appUserID` up front (via `configure(withAPIKey:appUserID:)`), you generally do not need `logIn` until that user signs out and back in as somebody else. + +## Verify + +1. Sign in your test user. In the RevenueCat dashboard, the Customer page shows the appUserID you passed to `logIn`, not `$RCAnonymousID:...`. +2. Sign out. `Purchases.shared.isAnonymous` becomes `true` again. +3. Sign in as a different user. Their entitlement state appears and the previous user's does not. +4. Make a sandbox purchase while anonymous, then `logIn`. The purchase remains attached to the signed in appUserID. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/kmp.md new file mode 100644 index 00000000..6580abb6 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/kmp.md @@ -0,0 +1,98 @@ +# revenuecat-identify-user: Kotlin Multiplatform + +`purchases-kmp` exposes `logIn` / `logOut` from commonMain. The coroutine extensions live in `com.revenuecat.purchases.kmp.ktx` and return `SuccessfulLogin(customerInfo, created)` for login. + +## Log in (commonMain) + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitLogIn +import com.revenuecat.purchases.kmp.models.PurchasesException + +suspend fun syncRevenueCat(appUserID: String) { + try { + val result = Purchases.sharedInstance.awaitLogIn(appUserID) + // result.customerInfo is the current entitlement state for this user. + // result.created is true the first time this appUserID reaches RevenueCat. + } catch (e: PurchasesException) { + // Log and surface to your error pipeline. + } +} +``` + +## Log out (commonMain) + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitLogOut +import com.revenuecat.purchases.kmp.models.PurchasesException + +suspend fun signOutRevenueCat() { + if (Purchases.sharedInstance.isAnonymous) return + try { + Purchases.sharedInstance.awaitLogOut() + } catch (e: PurchasesException) { + // Log; usually safe to ignore during sign-out. + } +} +``` + +## Wire it to your auth listener + +Call `syncRevenueCat` / `signOutRevenueCat` from commonMain whenever your auth state changes. Example using a `StateFlow`: + +```kotlin +class AuthObserver( + private val scope: CoroutineScope, + private val currentUserID: StateFlow, +) { + fun start() { + var previous: String? = null + scope.launch { + currentUserID.collect { next -> + when { + previous == null && next != null -> + syncRevenueCat(next) + previous != null && next == null -> + signOutRevenueCat() + previous != null && next != null && previous != next -> { + signOutRevenueCat() + syncRevenueCat(next) + } + } + previous = next + } + } + } +} +``` + +## Callback variants + +If you prefer to skip the coroutine dependency: + +```kotlin +Purchases.sharedInstance.logIn( + newAppUserID = appUserID, + onError = { /* ... */ }, + onSuccess = { customerInfo, created -> /* ... */ } +) + +Purchases.sharedInstance.logOut( + onError = { /* ... */ }, + onSuccess = { customerInfo -> /* ... */ } +) +``` + +## Notes + +- Use a stable opaque identifier (UUID / hash). Do not pass an email address, phone number, or a raw integer database id. +- `Purchases.sharedInstance.isAnonymous` is available in commonMain and is the correct guard before calling `awaitLogOut`. +- If your installed version of `purchases-kmp` does not expose `awaitLogIn` / `awaitLogOut` in `com.revenuecat.purchases.kmp.ktx`, prefer what the IDE autocompletes and see the `purchases-kmp` README. Some versions ship `Result`-based variants in a separate module. + +## Verify + +1. On each target, sign in your test user. The RevenueCat dashboard Customer page shows the appUserID you passed, not `$RCAnonymousID:...`. +2. Sign out. `isAnonymous` becomes `true` on both iOS and Android. +3. Sign in as a different user. Their entitlement state appears on both targets. +4. Native logs (Xcode console on iOS, logcat on Android) show the logIn / logOut lifecycle. The KMP SDK wraps the native SDKs, so the logs come from them. diff --git a/plugins/revenuecat/skills/revenuecat-identify-user/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/react-native.md new file mode 100644 index 00000000..a42c34c0 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-identify-user/platforms/react-native.md @@ -0,0 +1,88 @@ +# revenuecat-identify-user: React Native + +## Log in + +`Purchases.logIn(appUserID)` resolves to `{ customerInfo, created }`. + +```ts +import Purchases from 'react-native-purchases'; + +export async function syncRevenueCat(appUserID: string): Promise { + try { + const { customerInfo, created } = await Purchases.logIn(appUserID); + // customerInfo is the current entitlement state for this user. + // created is true the first time this appUserID reaches RevenueCat. + } catch (e) { + console.warn('RevenueCat logIn failed', e); + } +} +``` + +## Log out + +Calling `logOut` while the SDK is anonymous rejects with a `LogOutWithAnonymousUserError`. Guard with the anonymous-id prefix. + +```ts +export async function signOutRevenueCat(): Promise { + const info = await Purchases.getCustomerInfo(); + if (info.originalAppUserId.startsWith('$RCAnonymousID:')) return; + + try { + await Purchases.logOut(); + } catch (e) { + console.warn('RevenueCat logOut failed', e); + } +} +``` + +## Wire it to your auth state + +A `useEffect` watching the current user id is the natural place: + +```tsx +import { useEffect, useRef } from 'react'; +import { syncRevenueCat, signOutRevenueCat } from './revenuecatIdentity'; + +export function useRevenueCatIdentity(currentUserID: string | null) { + const previous = useRef(null); + + useEffect(() => { + const prev = previous.current; + previous.current = currentUserID; + + (async () => { + if (prev == null && currentUserID != null) { + await syncRevenueCat(currentUserID); + } else if (prev != null && currentUserID == null) { + await signOutRevenueCat(); + } else if (prev != null && currentUserID != null && prev !== currentUserID) { + await signOutRevenueCat(); + await syncRevenueCat(currentUserID); + } + })(); + }, [currentUserID]); +} +``` + +Use it from your root component: + +```tsx +function Root() { + const currentUserID = useCurrentUserID(); // from your auth library + useRevenueCatIdentity(currentUserID); + return ; +} +``` + +## Notes + +- Use a stable opaque identifier (UUID / hash). Do not pass an email address, phone number, or a raw integer database id. +- Under Expo, `Purchases.logIn` requires a development build. It throws in Expo Go because the native module is not linked. +- Any purchase made anonymously before `Purchases.logIn` is aliased onto the identified user automatically on the first login with that id. + +## Verify + +1. Sign in your test user. The RevenueCat dashboard Customer page shows the appUserID you passed, not `$RCAnonymousID:...`. +2. Sign out. A subsequent `Purchases.getCustomerInfo()` returns `originalAppUserId` starting with `$RCAnonymousID:`. +3. Sign in as a different user. Their entitlement state appears and the previous user's does not. +4. Platform logs (Xcode console on iOS, `adb logcat` filtered by `Purchases` on Android) show the logIn / logOut lifecycle. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/SKILL.md b/plugins/revenuecat/skills/revenuecat-migrate/SKILL.md new file mode 100644 index 00000000..80c964e5 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/SKILL.md @@ -0,0 +1,100 @@ +--- +name: revenuecat-migrate +description: Migrate to RevenueCat from raw StoreKit or Google Play Billing, or upgrade the RevenueCat SDK across a major version. Use when the user says migrate to RevenueCat, switch from StoreKit to RC, upgrade RevenueCat SDK, from v4 to v5, observer mode, RevenueCat major version upgrade, or already have in app purchases and want to add RevenueCat on iOS, Android, Kotlin Multiplatform, Flutter, or React Native. +--- + +# revenuecat-migrate: migrate to RevenueCat or upgrade the SDK + +Use this skill when the user wants to either adopt RevenueCat in an app that already ships in app purchases, or upgrade the RevenueCat SDK across a major version. + +These two paths share some concepts but have different risks. Identify which one applies before touching code. + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency -> read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root -> read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*` -> read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP) -> read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root -> read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Identify the migration path + +Ask the user (or infer from the codebase): + +- Path A: adoption. The app already has working in app purchases implemented directly against StoreKit or Google Play Billing. RevenueCat is being added on top. +- Path B: version upgrade. The app already uses RevenueCat, and the user wants to bump from one major version to the next (e.g. v4 to v5, v7 to v8). + +Both paths can happen at once (e.g. adopt RC today on the latest major version). Run Path A first, then Path B if needed. + +## 3. Shared concepts + +### Observer mode (Path A) + +Observer mode is the key lever for adopting RevenueCat without rewriting purchase code. The SDK observes transactions that your existing StoreKit / Billing code processes, sends them to the RevenueCat backend for validation, and updates subscriber state, but does not initiate or finish the transactions. Your existing purchase UI, receipt validation, and transaction finishing stay in place. + +Set this at configure time: + +- iOS: set `purchasesAreCompletedBy: .myApp` together with `storeKitVersion: .storeKit1` (or `.storeKit2`) on `Configuration.Builder`. They are separate parameters, not a single associated value. +- Android: `purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP)` on `PurchasesConfiguration.Builder`. +- Flutter: pass `const PurchasesAreCompletedByMyApp(storeKitVersion: StoreKitVersion.storeKit2)` to `PurchasesConfiguration`. +- React Native: pass `purchasesAreCompletedBy: { type: PURCHASES_ARE_COMPLETED_BY_TYPE.MY_APP, storeKitVersion: STOREKIT_VERSION.STOREKIT_2 }` in the configure call. + +The default is RevenueCat completed (`REVENUECAT` / `.revenueCat`), where the SDK owns the full flow. + +Once stable in observer mode, you can optionally cut over to full RevenueCat mode later by removing your own purchase plumbing and dropping the `purchasesAreCompletedBy` override. + +### Do not double process transactions + +When `purchasesAreCompletedBy` is set to `myApp`, RevenueCat does not finish transactions on iOS or acknowledge on Android. Your existing code must continue to do that. If you remove the `myApp` flag while leaving your old transaction finishing code in place, transactions get acknowledged twice and subscriber state can appear inconsistent. + +Exactly one side must own finishing / acknowledging. Pick a side and remove the other. + +### User continuity (Path A) + +If the app already has its own authentication system, call `Purchases.logIn(existingAppUserID)` once RevenueCat is configured. This attaches the prior purchase history to the right RevenueCat user on ingestion. Without this step, existing purchases get recorded against an anonymous RC user and cannot be matched to the app's actual user records later. + +Only skip this if the app has no notion of authenticated users. + +### Version bumps change required fields (Path B) + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: in the relevant SDK repo on GitHub. Walk entries from your installed version up to the target. +- Migration guides: search the SDK repo for files matching `*MIGRATION*.md` or a `migrations/` directory. Major bumps usually ship a dedicated guide there. The release notes for the major version on the repo's GitHub releases page typically link to it. +- Release notes: each major version's release notes on the repo's GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. The platform file under `platforms/` for your target lists the exact repo to consult. + +### Plan then migrate + +Work in this order on every platform: + +1. Bump the SDK to the new major version in a branch. +2. Fix compile errors using the CHANGELOG deprecations and removals as a guide. +3. Fix runtime behavior by reading the SDK logs on first launch. +4. Run the existing test suite and manual sandbox scenarios before merging. + +## 4. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file covers both migration paths for that platform. + +## 5. Verify + +Do not declare migration done until: + +1. The app builds on the new SDK version with no warnings from deprecated APIs you care about. +2. A sandbox purchase succeeds and the transaction shows up on the RevenueCat dashboard Sandbox view with the expected appUserID. +3. An existing subscriber from before the migration opens the app, and their entitlement state is correct. For Path A this proves the observer mode ingest worked. For Path B this proves the version bump did not drop state. +4. You have removed the debug log level override before shipping. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/platforms/android.md b/plugins/revenuecat/skills/revenuecat-migrate/platforms/android.md new file mode 100644 index 00000000..7abd37ef --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/platforms/android.md @@ -0,0 +1,80 @@ +# revenuecat-migrate: Android (native Kotlin/Java) + +Covers two paths: adopting RevenueCat in an app that already uses Google Play Billing Library directly, and upgrading the RevenueCat SDK across a major version. + +Always check `CHANGELOG.md` in the installed version of `purchases-android`. The SDK's CHANGELOG is the authoritative source when specifics conflict with this file. + +## Path A: adopt RevenueCat with existing Play Billing code + +Use observer mode. Your existing Play Billing code keeps owning the purchase flow and keeps acknowledging purchases. + +### Install the SDK + +See `../../integrate-revenuecat/platforms/android.md` for dependency specifics. Target a recent 8.x or newer release. + +### Configure in observer mode + +```kotlin +import com.revenuecat.purchases.LogLevel +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesAreCompletedBy +import com.revenuecat.purchases.PurchasesConfiguration + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + Purchases.logLevel = LogLevel.DEBUG + Purchases.configure( + PurchasesConfiguration.Builder(this, "goog_YOUR_PUBLIC_SDK_KEY") + .purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP) + .build() + ) + } +} +``` + +In observer mode, RevenueCat does not acknowledge purchases. Your existing code must continue to call `BillingClient.acknowledgePurchase(...)` (or `consumePurchase` for consumables) within 3 days. If you forget, Play refunds the charge. + +### Tie existing users to RevenueCat + +After login: + +```kotlin +Purchases.sharedInstance.logIn(appUserID, callback) +``` + +This attaches past Play Billing purchases to the right RevenueCat user as transactions are ingested. + +### Verify observer mode + +Run the app, trigger a sandbox purchase using your existing code with a license tester account installed from the Internal Testing track. The transaction should appear on the RevenueCat dashboard Sandbox view within seconds, attached to the right appUserID. + +### Cutover to full RevenueCat mode (optional, later) + +Once observer mode is stable: + +1. Remove `.purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP)` from the builder. `REVENUECAT` is the default. +2. Replace Play Billing purchase calls with `Purchases.sharedInstance.purchase(...)`. +3. Remove your `acknowledgePurchase` code. RevenueCat now handles it. + +Do not ship a build where both the app and RevenueCat try to acknowledge the same purchase. + +## Path B: upgrade the RevenueCat SDK major version + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: . Walk entries from your installed version up to the target. +- Migration guides: search the repo for files matching `*MIGRATION*.md` or a `migrations/` directory; major bumps usually ship a dedicated guide there. The release notes for the major version on typically link to it. +- Release notes: each major version's release notes on the GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. + +## Verify + +After migration: + +1. App builds at the new SDK version with `Purchases.logLevel = LogLevel.DEBUG`. +2. Logcat at launch shows `Purchases: [Purchases] - INFO: Purchases is configured`. +3. A sandbox purchase from a license tester on the Internal Testing track shows on the RevenueCat dashboard Sandbox view, attached to the correct appUserID. +4. A user with an existing active subscription still has it after relaunch. +5. Log level is dropped before the next release. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-migrate/platforms/flutter.md new file mode 100644 index 00000000..10e17f92 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/platforms/flutter.md @@ -0,0 +1,78 @@ +# revenuecat-migrate: Flutter + +Covers two paths: adopting RevenueCat in a Flutter app that already has in app purchases (typically via `in_app_purchase` or a custom MethodChannel wrapper), and upgrading `purchases_flutter` across a major version. + +Always check the CHANGELOG in the installed version of `purchases_flutter`. `purchases_flutter` major bumps typically correspond to `purchases-ios` / `purchases-android` major bumps, so the underlying native CHANGELOGs also apply. + +## Path A: adopt RevenueCat with existing in app purchase code + +Use observer mode. Your existing purchase code (whether Dart based or native via `in_app_purchase`) keeps owning the purchase flow. + +### Install + +Add `purchases_flutter` to `pubspec.yaml` and run `flutter pub get`. See `../../integrate-revenuecat/platforms/flutter.md` for the setup details. + +### Configure in observer mode + +```dart +import 'dart:io'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Purchases.setLogLevel(LogLevel.debug); + + final apiKey = Platform.isIOS + ? 'appl_YOUR_IOS_PUBLIC_SDK_KEY' + : 'goog_YOUR_ANDROID_PUBLIC_SDK_KEY'; + + final config = PurchasesConfiguration(apiKey) + ..purchasesAreCompletedBy = const PurchasesAreCompletedByMyApp( + storeKitVersion: StoreKitVersion.storeKit2, + ); + + await Purchases.configure(config); + + runApp(const MyApp()); +} +``` + +Pick `StoreKitVersion.storeKit1` if your existing iOS code uses StoreKit 1. The setting only affects the iOS side; Android ignores it. + +In observer mode: + +- iOS: your StoreKit code must continue to finish transactions. +- Android: your Play Billing code must continue to acknowledge purchases within 3 days. + +### Tie existing users + +```dart +await Purchases.logIn(existingAppUserID); +``` + +Call this after your app's authentication completes. + +### Cutover to full RevenueCat mode (optional, later) + +Remove the `purchasesAreCompletedBy` assignment. Default is `PurchasesAreCompletedByRevenueCat`. Replace your purchase code with `Purchases.purchasePackage(...)` or `Purchases.purchaseStoreProduct(...)`. Remove your own transaction finishing / acknowledgement code at the same time. + +## Path B: upgrade `purchases_flutter` across a major version + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: . Walk entries from your installed version up to the target. +- Migration guides: search the repo for files matching `*MIGRATION*.md` or a `migrations/` directory; major bumps usually ship a dedicated guide there. The release notes for the major version on typically link to it. +- Release notes: each major version's release notes on the GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. + +## Verify + +After migration: + +1. `flutter run` builds on both iOS and Android. +2. Xcode console (iOS) shows `[Purchases] - INFO: Purchases is configured`. Logcat (Android) shows `Purchases: [Purchases] - INFO: Purchases is configured`. +3. A sandbox purchase on each platform shows on the RevenueCat dashboard Sandbox view with the right appUserID. +4. A user with a pre migration active subscription still shows that entitlement active. +5. `await Purchases.setLogLevel(LogLevel.info);` before shipping. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-migrate/platforms/ios.md new file mode 100644 index 00000000..2d475b94 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/platforms/ios.md @@ -0,0 +1,86 @@ +# revenuecat-migrate: iOS (native) + +Covers two paths: adopting RevenueCat in an app that already uses StoreKit, and upgrading the RevenueCat SDK across a major version. + +Always check the `CHANGELOG.md` in the installed version of `purchases-ios`. The SDK's migration guide (shipped as DocC in the repo under `Sources/DocCDocumentation`) is the authoritative source when specifics conflict with this file. + +## Path A: adopt RevenueCat with existing StoreKit code + +Use observer mode. Your existing StoreKit code keeps owning the purchase flow. + +### Install the SDK + +See `../../integrate-revenuecat/platforms/ios.md` for dependency manager specifics. You want a recent 5.x release. + +### Configure in observer mode + +Pick the StoreKit version your app already uses. + +If the app uses StoreKit 1 (`SKPaymentQueue`, `SKProduct`, `SKPaymentTransaction`): + +```swift +import RevenueCat + +Purchases.logLevel = .debug +Purchases.configure( + with: Configuration.Builder(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY") + .with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit1) + .build() +) +``` + +If the app uses StoreKit 2 (`Product`, `Transaction`, async/await): + +```swift +Purchases.configure( + with: Configuration.Builder(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY") + .with(purchasesAreCompletedBy: .myApp, storeKitVersion: .storeKit2) + .build() +) +``` + +In observer mode, RevenueCat does not call `SKPaymentQueue.default().finishTransaction(_:)` on your behalf, and does not call `Transaction.finish()` for StoreKit 2. Keep your existing finishing code in place. + +### Tie existing users to RevenueCat + +If your app has a user ID after login: + +```swift +try await Purchases.shared.logIn(appUserID) +``` + +This attaches the StoreKit purchases already associated with the device to the right RevenueCat user as transactions stream in. + +### Verify observer mode is working + +Build and run. Trigger a sandbox purchase with your existing code. In the RevenueCat dashboard Sandbox view, the transaction should appear within a few seconds, attached to the appUserID you logged in with. + +### Cutover to full RevenueCat mode (optional, later) + +Once observer mode is stable in production, you can migrate purchase code to RevenueCat: + +1. Remove the `.with(purchasesAreCompletedBy: ...)` call from the configuration. The default is RevenueCat completed. +2. Replace your StoreKit purchase code with `Purchases.shared.purchase(product:)` or `Purchases.shared.purchase(package:)`. +3. Remove your own transaction finishing code. RevenueCat now owns this. + +Do not ship an interim build where both sides try to finish transactions. + +## Path B: upgrade the RevenueCat SDK major version + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: . Walk entries from your installed version up to the target. +- Migration guides: search the repo for files matching `*MIGRATION*.md` or a `migrations/` directory; major bumps usually ship a dedicated guide there. The release notes for the major version on typically link to it. +- Release notes: each major version's release notes on the GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. + +## Verify + +After migration: + +1. App builds with `Purchases.logLevel = .debug`. +2. Xcode console shows `[Purchases] - INFO: Purchases is configured` at launch. +3. A fresh sandbox purchase shows on the RevenueCat dashboard Sandbox view. +4. A user who had an active subscription before the upgrade still shows that entitlement active. +5. Debug log level is removed before the next release build. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-migrate/platforms/kmp.md new file mode 100644 index 00000000..f638d1b0 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/platforms/kmp.md @@ -0,0 +1,61 @@ +# revenuecat-migrate: Kotlin Multiplatform + +`purchases-kmp` wraps `purchases-ios` and `purchases-android`. Migration on KMP is a thin shim over platform native migration, so the platform files `ios.md` and `android.md` remain the source of truth. + +## Path A: adopt RevenueCat with existing native IAP code + +If the app has existing StoreKit or Play Billing code under each platform's native source set, follow the observer mode setup in `ios.md` and `android.md`. The shared KMP entry point just passes the flag through. + +### Configure in observer mode from shared code + +Expected shape (check the installed version of `purchases-kmp-core`; its `expect`/`actual` surface has changed across releases): + +```kotlin +import com.revenuecat.purchases.kmp.LogLevel +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.PurchasesAreCompletedBy +import com.revenuecat.purchases.kmp.PurchasesConfiguration + +fun initRevenueCat(apiKey: String) { + Purchases.logLevel = LogLevel.DEBUG + Purchases.configure( + PurchasesConfiguration.Builder(apiKey = apiKey) + .purchasesAreCompletedBy(PurchasesAreCompletedBy.MY_APP) + .build() + ) +} +``` + +If `PurchasesAreCompletedBy` or the builder method name differs in your installed version, rely on the IDE's autocomplete over this file. The KMP wrapper's type names track the native names but are occasionally renamed to `kmp`-friendly equivalents. + +### StoreKit version on iOS + +On the iOS side, observer mode still requires explicit `storeKitVersion` selection. If your existing iOS code uses StoreKit 1, pass that version through. If StoreKit 2, pass that. The KMP SDK forwards the selection to `purchases-ios`. + +### Acknowledgement on Android + +On Android, observer mode still means your own code must acknowledge purchases within 3 days. The KMP wrapper does not change this. + +### Tie existing users + +Call `Purchases.logIn(appUserID)` from shared code after the user is known. + +## Path B: upgrade the SDK major version + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: . Walk entries from your installed version up to the target. +- Migration guides: search the repo for files matching `*MIGRATION*.md` or a `migrations/` directory; major bumps usually ship a dedicated guide there. The release notes for the major version on typically link to it. +- Release notes: each major version's release notes on the GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. + +## Verify + +After migration: + +1. Both iOS and Android targets build at the new version with debug logging on. +2. The native SDK configure banner appears in each target's platform console. +3. A sandbox purchase on each target shows on the RevenueCat dashboard. +4. An existing subscriber still has their entitlement active on each target. +5. Log level dropped before release. diff --git a/plugins/revenuecat/skills/revenuecat-migrate/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-migrate/platforms/react-native.md new file mode 100644 index 00000000..56437d84 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-migrate/platforms/react-native.md @@ -0,0 +1,85 @@ +# revenuecat-migrate: React Native + +Covers two paths: adopting RevenueCat in a React Native app that already has in app purchases (typically via `react-native-iap` or a custom native module), and upgrading `react-native-purchases` across a major version. + +Always check the CHANGELOG in the installed version of `react-native-purchases`. Major bumps of `react-native-purchases` usually track native SDK major bumps, so the underlying native CHANGELOGs also apply. + +## Path A: adopt RevenueCat with existing in app purchase code + +Use observer mode. Your existing purchase code (JS or native module) keeps owning the purchase flow. + +### Install + +```bash +npm install react-native-purchases +cd ios && pod install && cd .. +``` + +For Expo managed projects: + +```bash +npx expo install react-native-purchases +npx expo prebuild --clean # dev client required, Expo Go does not work +``` + +### Configure in observer mode + +```ts +import { Platform } from 'react-native'; +import Purchases, { + LOG_LEVEL, + PURCHASES_ARE_COMPLETED_BY_TYPE, + STOREKIT_VERSION, +} from 'react-native-purchases'; + +Purchases.setLogLevel(LOG_LEVEL.DEBUG); + +const apiKey = Platform.OS === 'ios' + ? 'appl_YOUR_IOS_PUBLIC_SDK_KEY' + : 'goog_YOUR_ANDROID_PUBLIC_SDK_KEY'; + +Purchases.configure({ + apiKey, + purchasesAreCompletedBy: { + type: PURCHASES_ARE_COMPLETED_BY_TYPE.MY_APP, + storeKitVersion: STOREKIT_VERSION.STOREKIT_2, + }, +}); +``` + +Pass `STOREKIT_VERSION.STOREKIT_1` if your existing iOS code uses StoreKit 1. The setting only affects the iOS side. + +In observer mode: + +- iOS: your StoreKit code must continue to finish transactions. +- Android: your Play Billing code must continue to acknowledge purchases within 3 days. + +### Tie existing users + +```ts +await Purchases.logIn(existingAppUserID); +``` + +### Cutover to full RevenueCat mode (optional, later) + +Drop the `purchasesAreCompletedBy` field from the configure call (the default is `REVENUECAT`). Replace your purchase code with `Purchases.purchasePackage(...)` or `Purchases.purchaseStoreProduct(...)`. Remove your own transaction finishing / acknowledgement code at the same time. + +## Path B: upgrade `react-native-purchases` across a major version + +Major version upgrades change configuration shape, drop deprecated APIs, and shift default behavior in ways that move with each release. This skill does not duplicate the per-version diff. Read the canonical sources from the SDK repo: + +- CHANGELOG: . Walk entries from your installed version up to the target. +- Migration guides: search the repo for files matching `*MIGRATION*.md` or a `migrations/` directory; major bumps usually ship a dedicated guide there. The release notes for the major version on typically link to it. +- Release notes: each major version's release notes on the GitHub releases page summarize the breaking changes. + +Treat the SDK repo's docs as authoritative. Any version-specific diff written here would drift out of date. + +## Verify + +After migration: + +1. App builds and launches on both iOS and Android. +2. Native platform console (Xcode / logcat) shows the `Purchases is configured` banner. +3. A sandbox purchase on each platform shows on the RevenueCat dashboard Sandbox view with the right appUserID. +4. A user with a pre migration active subscription still shows that entitlement active. +5. `Purchases.setLogLevel(LOG_LEVEL.INFO);` before shipping. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/SKILL.md b/plugins/revenuecat/skills/revenuecat-paywall/SKILL.md new file mode 100644 index 00000000..af2b312c --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/SKILL.md @@ -0,0 +1,57 @@ +--- +name: revenuecat-paywall +description: Display a RevenueCat paywall inside an app using the RevenueCatUI SDK. Use when the user asks to add a paywall, show a RevenueCat paywall, present PaywallView, integrate RevenueCatUI, gate a premium screen with a paywall, launch PaywallActivity, call presentPaywall or presentPaywallIfNeeded, or show the dashboard configured paywall UI on iOS, Android, Kotlin Multiplatform, Flutter, or React Native. +--- + +# revenuecat-paywall: display a RevenueCat paywall + +Use this skill when the user wants to show a paywall that is built and configured in the RevenueCat dashboard, using the native RevenueCatUI components. This skill does not cover building a custom paywall from scratch. For that, use `revenuecat-purchase-flow` (when available) and `Purchases.getOfferings(...)` directly. + +Prerequisite: `integrate-revenuecat` has already run. `Purchases.configure(...)` must succeed before a paywall can load. + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency. `react-native-purchases-ui` is the paywall package. Read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root. The paywall package is `purchases_ui_flutter`. Read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*`. The paywall module is `purchases-kmp-ui`. Read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP). The paywall dependency is `com.revenuecat.purchases:purchases-ui`. Read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root. The paywall product is `RevenueCatUI`. Read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Shared concepts (all platforms) + +- Paywalls require an Offering with a paywall attached in the RevenueCat dashboard. The SDK pulls offerings via `getOfferings()`. If no offering has a paywall configured, RevenueCatUI falls back to a default paywall layout, which is not what you want in production. +- Offering vs. entitlement. Users purchase a product through a package in an offering. Access is granted via an entitlement (typically `"premium"` or `"pro"`). Gate premium features on the entitlement, not on the offering. +- Three presentation patterns: + - (a) First launch modal for users without the entitlement, typically driven by a "present if needed" helper that checks the entitlement and only shows the paywall when missing. + - (b) Gated premium screen. The user taps a premium feature and the paywall opens before the screen loads. + - (c) Conditional present on a CTA tap, such as an "Upgrade" button in settings. +- RevenueCatUI owns the purchase flow. Do not call `Purchases.purchase(...)` manually alongside a RevenueCatUI paywall. The paywall calls it internally. Listen for the dismiss or purchase completed callback to react in app code. +- Close button is opt in on most platforms. Pass `displayCloseButton = true` (iOS / Flutter / RN) or `setShouldDisplayDismissButton(true)` (Android / KMP) when the paywall is presented modally and the user needs a way out. Skip it when presenting behind a sheet with its own grabber, or when wrapping the paywall in a navigation controller. +- If the app needs a fully custom UI, do not use this skill. Call `Purchases.getOfferings()` and render your own components. RevenueCatUI is only for dashboard templated paywalls. + +## 3. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file is self contained: install command, exact snippet to present the paywall, and the callback shape you listen to. + +## 4. Verify + +Do not claim the integration is complete until: + +1. The project builds on the target platform. +2. The app launches, the code path that presents the paywall runs, and the paywall UI renders with the template configured in the dashboard (not the default fallback layout). +3. Tapping a package and completing a sandbox purchase dismisses the paywall and fires the purchase completed callback (or, for imperative APIs, resolves with a `PURCHASED` result). +4. Closing the paywall without purchasing fires the dismiss / cancelled callback. + +If the paywall shows the default fallback layout instead of your template, the offering does not have a paywall attached in the dashboard. Fix this in the dashboard, then retry. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/platforms/android.md b/plugins/revenuecat/skills/revenuecat-paywall/platforms/android.md new file mode 100644 index 00000000..84546df9 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/platforms/android.md @@ -0,0 +1,114 @@ +# revenuecat-paywall: Android (native Kotlin) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +Add the `purchases-ui` artifact alongside `purchases`: + +```kotlin +// app/build.gradle.kts +dependencies { + implementation("com.revenuecat.purchases:purchases:") + implementation("com.revenuecat.purchases:purchases-ui:") +} +``` + +`purchases-ui` depends on Jetpack Compose. If your app is not already a Compose app, enable Compose in the module: + +```kotlin +android { + buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = "..." } +} +``` + +## Implement + +Two APIs exist: the `Paywall` composable and the `PaywallActivityLauncher`. Pick one based on how your app is built. + +### Compose: embed `Paywall` directly + +```kotlin +import androidx.compose.runtime.Composable +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.models.StoreTransaction +import com.revenuecat.purchases.ui.revenuecatui.Paywall +import com.revenuecat.purchases.ui.revenuecatui.PaywallListener +import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions + +@Composable +fun PremiumUpsell(onDismiss: () -> Unit) { + val options = PaywallOptions.Builder(dismissRequest = onDismiss) + .setShouldDisplayDismissButton(true) + .setListener(object : PaywallListener { + override fun onPurchaseCompleted( + customerInfo: CustomerInfo, + storeTransaction: StoreTransaction, + ) { + onDismiss() + } + + override fun onPurchaseError(error: PurchasesError) { + // show a toast / log + } + }) + .build() + + Paywall(options = options) +} +``` + +`PaywallOptions.Builder` also has `setOffering(offering)` if you need to force a specific offering. Without it, the paywall uses `Offerings.current`. + +### Activity: launch from any `ComponentActivity` or `Fragment` + +```kotlin +import androidx.activity.ComponentActivity +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult +import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResultHandler + +class MainActivity : ComponentActivity() { + + private val paywallLauncher = PaywallActivityLauncher( + resultCaller = this, + resultHandler = PaywallResultHandler { result -> + when (result) { + is PaywallResult.Purchased -> { /* entitlement granted */ } + is PaywallResult.Cancelled -> { /* user dismissed */ } + is PaywallResult.Restored -> { /* restore succeeded */ } + is PaywallResult.Error -> { /* error */ } + } + }, + ) + + private fun openPaywall() { + paywallLauncher.launch( + offering = null, // null = Offerings.current + shouldDisplayDismissButton = true, + ) + } +} +``` + +`PaywallActivityLauncher` must be instantiated during the host `Activity` or `Fragment`'s `onCreate`. Calling `launch(...)` any time after that opens `PaywallActivity` in full screen. + +## Notes + +- `PaywallActivityLauncher` does not support launching from a plain `Context`. It requires an `ActivityResultCaller` (a `ComponentActivity` or `Fragment`). +- `PaywallListener` on the composable and `PaywallResultHandler` on the launcher report overlapping events. Pick one path per entry point; do not mix them. +- `setShouldDisplayDismissButton(true)` only affects original template paywalls. V2 Paywalls ignore it and render their own dismiss affordance. +- If the dashboard offering has no paywall attached, `Paywall` renders a default template. Confirm the offering has a paywall in the RevenueCat dashboard. +- Do not call `Purchases.sharedInstance.purchase(...)` alongside the paywall. The RevenueCatUI paywall runs the purchase internally. + +## Verify + +Run the app on a device or emulator signed into a Google sandbox tester: + +1. Trigger the code path that opens the paywall. The template configured in the dashboard renders. +2. Tap a package and complete a test purchase. The paywall closes and either `PaywallListener.onPurchaseCompleted` fires (composable) or the result handler receives `PaywallResult.Purchased` (activity). +3. Dismiss with the close button. `dismissRequest` (composable) or `PaywallResult.Cancelled` (activity) fires. +4. Run `adb logcat -s Purchases` and confirm a successful transaction log line around the purchase. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-paywall/platforms/flutter.md new file mode 100644 index 00000000..e09c16d3 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/platforms/flutter.md @@ -0,0 +1,121 @@ +# revenuecat-paywall: Flutter + +Paywalls ship in a separate package, `purchases_ui_flutter`, that must be added alongside `purchases_flutter`. + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +In `pubspec.yaml`: + +```yaml +dependencies: + purchases_flutter: ^ + purchases_ui_flutter: ^ +``` + +Then: + +```bash +flutter pub get +cd ios && pod install && cd .. +``` + +Minimum iOS deployment target is 13.0 for `purchases_flutter`, but `purchases_ui_flutter` uses `RevenueCatUI` under the hood which needs iOS 15. Update `ios/Podfile`: + +```ruby +platform :ios, '15.0' +``` + +Android minimum SDK is 21. `purchases_ui_flutter` uses Jetpack Compose on Android; the Flutter embedding wraps that for you. + +## Implement + +Two APIs are available: the imperative `RevenueCatUI.presentPaywall(...)` method and the declarative `PaywallView` widget. Prefer the imperative one when the paywall is a one shot modal. Use the widget when you want to embed the paywall inside a Flutter route. + +### Imperative: `presentPaywall` + +```dart +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; + +Future openPaywall() async { + final result = await RevenueCatUI.presentPaywall( + displayCloseButton: true, + ); + + switch (result) { + case PaywallResult.purchased: + case PaywallResult.restored: + // entitlement granted + break; + case PaywallResult.cancelled: + // user dismissed + break; + case PaywallResult.error: + case PaywallResult.notPresented: + break; + } +} +``` + +### Imperative: `presentPaywallIfNeeded` + +Gate a flow on an entitlement. The SDK checks `customerInfo` first and only shows the paywall if the entitlement is not active: + +```dart +final result = await RevenueCatUI.presentPaywallIfNeeded( + 'premium', + displayCloseButton: true, +); + +if (result == PaywallResult.purchased || result == PaywallResult.notPresented) { + // user has access +} +``` + +### Declarative: `PaywallView` widget + +```dart +import 'package:flutter/material.dart'; +import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; + +class PremiumPage extends StatelessWidget { + const PremiumPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PaywallView( + displayCloseButton: true, + onPurchaseCompleted: (customerInfo, storeTransaction) { + Navigator.of(context).pop(true); + }, + onDismiss: () => Navigator.of(context).pop(false), + onPurchaseError: (error) { + // surface the error + }, + ), + ); + } +} +``` + +To target a specific offering, pass it via the `offering:` parameter (both methods and the widget accept an `Offering` pulled from `Purchases.getOfferings()`). + +## Notes + +- `purchases_ui_flutter` supports iOS and Android only. Web, macOS, Windows, and Linux targets are not supported. +- Do not call `Purchases.purchasePackage(...)` inside the widget callbacks or around the imperative method. The paywall runs the purchase itself. +- `displayCloseButton: true` only affects original template paywalls. V2 Paywalls render their own close affordance. +- `presentPaywall` returns a `PaywallResult` enum. `notPresented` only appears for `presentPaywallIfNeeded` when the entitlement is already active. +- `PaywallView` is a `StatelessWidget` that hosts a native platform view. Do not wrap it in a container that constrains its height to zero; it needs to fill available space. + +## Verify + +Run on a device or simulator with a sandbox account configured: + +1. Trigger the paywall flow. The dashboard configured template renders. +2. Purchase a package in the sandbox. Either the future returned by `presentPaywall` resolves to `PaywallResult.purchased`, or `PaywallView.onPurchaseCompleted` fires with a non-null `CustomerInfo`. +3. Close without purchasing. The future resolves to `PaywallResult.cancelled` or `onDismiss` fires. +4. After a successful purchase, call `Purchases.getCustomerInfo()` and confirm `customerInfo.entitlements.active['premium']` exists. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-paywall/platforms/ios.md new file mode 100644 index 00000000..b0e29e47 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/platforms/ios.md @@ -0,0 +1,135 @@ +# revenuecat-paywall: iOS (native) + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. If GitHub is unreachable, ask the user for a version to pin or check their existing project files for one. + +`RevenueCatUI` ships in the same repo as `RevenueCat`. If the app already has `RevenueCat` via Swift Package Manager, add the `RevenueCatUI` product to the app target as well. + +### Swift Package Manager + +In Xcode: target -> General -> Frameworks, Libraries, and Embedded Content -> +, pick the `purchases-ios` package and add the `RevenueCatUI` library. + +For a `Package.swift`-based project: + +```swift +.target( + name: "MyApp", + dependencies: [ + .product(name: "RevenueCat", package: "purchases-ios"), + .product(name: "RevenueCatUI", package: "purchases-ios"), + ] +) +``` + +### CocoaPods + +```ruby +pod 'RevenueCat' +pod 'RevenueCatUI' +``` + +Then `pod install`. + +Minimum deployment target for `PaywallView` is iOS 15.0. `RevenueCatUI` is unavailable on tvOS. + +## Implement + +### SwiftUI: gate a premium screen with `.sheet` + +```swift +import SwiftUI +import RevenueCat +import RevenueCatUI + +struct PremiumFeatureScreen: View { + @State private var isShowingPaywall = false + @State private var hasEntitlement = false + + var body: some View { + Group { + if hasEntitlement { + PremiumContentView() + } else { + Button("Unlock premium") { isShowingPaywall = true } + } + } + .sheet(isPresented: $isShowingPaywall) { + PaywallView(displayCloseButton: true) + } + .task { + let info = try? await Purchases.shared.customerInfo() + hasEntitlement = info?.entitlements["premium"]?.isActive == true + } + } +} +``` + +`PaywallView()` with no arguments loads `Offerings.current`. To present a specific offering, pass it explicitly: + +```swift +PaywallView(offering: offering, displayCloseButton: true) +``` + +### SwiftUI: present if needed + +`RevenueCatUI` ships a view modifier that checks an entitlement and only presents the paywall if the entitlement is not active: + +```swift +ContentView() + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "premium") +``` + +Use the full overload to react to lifecycle events: + +```swift +ContentView() + .presentPaywallIfNeeded( + requiredEntitlementIdentifier: "premium", + purchaseCompleted: { customerInfo in + // granted + }, + onDismiss: { + // user closed without buying + } + ) +``` + +### UIKit + +```swift +import UIKit +import RevenueCatUI + +final class SettingsViewController: UIViewController { + @IBAction func openPaywall() { + let paywall = PaywallViewController(displayCloseButton: true) + paywall.delegate = self + present(paywall, animated: true) + } +} + +extension SettingsViewController: PaywallViewControllerDelegate { + func paywallViewController( + _ controller: PaywallViewController, + didFinishPurchasingWith customerInfo: CustomerInfo + ) { + controller.dismiss(animated: true) + } +} +``` + +## Notes + +- `PaywallView` and `PaywallViewController` require iOS 15+. On iOS 13-14, there is no RevenueCatUI paywall; fall back to a custom UI. +- Do not call `Purchases.shared.purchase(package:)` from inside code that also shows a `PaywallView`. The paywall runs the purchase itself and will double charge if you wire a second path. +- `displayCloseButton` only affects the "original template" paywalls. V2 Paywalls built in the dashboard render their own close affordance per the template. +- To react to specific events (purchase started, restore completed, etc.), attach the view modifiers such as `.onPurchaseCompleted { customerInfo in ... }` or `.onRestoreCompleted { customerInfo in ... }` directly to the `PaywallView`. + +## Verify + +Run the app on a device or simulator signed into a sandbox account: + +1. Trigger the code path that presents the paywall. The dashboard configured template renders. If you see the default RevenueCat fallback template, the offering in the dashboard has no paywall attached. +2. Tap a package and complete a sandbox purchase. The paywall dismisses and `customerInfo.entitlements["premium"].isActive` is `true` on the next `Purchases.shared.customerInfo()` call. +3. Close the paywall without purchasing. The sheet dismisses and your `.onDismiss` / delegate method fires. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-paywall/platforms/kmp.md new file mode 100644 index 00000000..130f780f --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/platforms/kmp.md @@ -0,0 +1,96 @@ +# revenuecat-paywall: Kotlin Multiplatform + +`purchases-kmp-ui` is a Compose Multiplatform wrapper over the native RevenueCatUI paywalls on iOS and Android. The composable is `Paywall`; the configuration type is `PaywallOptions`, same as the native Android SDK but in the `com.revenuecat.purchases.kmp.ui.revenuecatui` package. + +## Install + +Find the latest stable release at and substitute that tag for `` in the snippet below. The KMP tag uses a `+` format (e.g. `2.10.2+17.55.1`), where the part before `+` is the KMP wrapper version and the part after is the bundled `purchases-hybrid-common` version. Use the full tag string as the artifact version first; if Gradle interprets the `+` as a wildcard, fall back to the wrapper portion only (e.g. `2.10.2`). If GitHub is unreachable, ask the user for a version to pin. + +In the shared module's `build.gradle.kts`, add the UI artifact to `commonMain`: + +```kotlin +kotlin { + // your targets: androidTarget(), iosX64(), iosArm64(), iosSimulatorArm64(), etc. + + sourceSets { + commonMain.dependencies { + implementation("com.revenuecat.purchases:purchases-kmp-core:") + implementation("com.revenuecat.purchases:purchases-kmp-ui:") + } + } +} +``` + +Compose Multiplatform must already be set up in the shared module (the `org.jetbrains.compose` plugin and the compose dependencies). If it is not, `Paywall` will not compile. + +On iOS, the UI artifact bridges through the `RevenueCatUI` framework. Make sure your Kotlin framework links against it. For setups using the Kotlin CocoaPods plugin, the pod integration handles this automatically. For pure SPM setups, follow the iOS linking section of the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp#readme). + +## Implement + +Shared composable: + +```kotlin +import androidx.compose.runtime.Composable +import com.revenuecat.purchases.kmp.CustomerInfo +import com.revenuecat.purchases.kmp.PurchasesError +import com.revenuecat.purchases.kmp.models.StoreTransaction +import com.revenuecat.purchases.kmp.Package +import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall +import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallListener +import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallOptions + +@Composable +fun PremiumUpsell(onDismiss: () -> Unit) { + val options = PaywallOptions(dismissRequest = onDismiss) { + shouldDisplayDismissButton = true + listener = object : PaywallListener { + override fun onPurchaseCompleted( + customerInfo: CustomerInfo, + storeTransaction: StoreTransaction, + ) { + onDismiss() + } + + override fun onPurchaseError(error: PurchasesError) { + // surface the error + } + } + } + + Paywall(options) +} +``` + +The `PaywallOptions` factory is a DSL builder. The equivalent explicit form is: + +```kotlin +val options = PaywallOptions.Builder(dismissRequest = onDismiss) + .apply { + shouldDisplayDismissButton = true + listener = /* ... */ + } + .build() +``` + +Set a specific offering via the `offering` property on the builder. If left `null`, the paywall loads `Offerings.current`. + +### Presenting from platform UI + +- Android: host the `Paywall` composable inside any Compose screen (including an `AndroidView`-free `ComponentActivity.setContent { ... }`). Navigation and dismissal are handled through `dismissRequest`. +- iOS: embed the shared `Paywall` composable inside a Compose Multiplatform `UIViewController` (e.g. `ComposeUIViewController { Paywall(options) }`), then present that view controller from your SwiftUI or UIKit host. + +## Notes + +- The `PaywallListener` callbacks, `PaywallOptions` surface, and the `dismissRequest` contract mirror the native Android SDK. See `purchases-android` docs for detailed semantics. +- Do not call `Purchases.purchase(...)` from outside the paywall. The paywall runs the purchase itself and calls the listener with the result. +- The exact group/artifact coordinates for `purchases-kmp-ui` have evolved across 1.x releases. If the IDE flags the dependency as unresolved, prefer what shows up in the [purchases-kmp README](https://github.com/RevenueCat/purchases-kmp) for your installed version over the snippet above. +- Compose Multiplatform paywalls require the Compose runtime on both targets. Desktop, web, and other non mobile targets are not supported by the paywall module. + +## Verify + +Run the Android target and the iOS target, each with a sandbox tester account: + +1. Trigger the composable on each platform. The dashboard configured paywall renders inside the shared Compose host. +2. Complete a sandbox purchase. The paywall dismisses via `dismissRequest` and `PaywallListener.onPurchaseCompleted` fires with a non-null `CustomerInfo`. +3. Dismiss without purchasing. `dismissRequest` fires with no listener call. +4. Inspect logs on each platform (Xcode console for iOS, `adb logcat -s Purchases` for Android) to confirm the underlying native SDK ran the transaction. diff --git a/plugins/revenuecat/skills/revenuecat-paywall/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-paywall/platforms/react-native.md new file mode 100644 index 00000000..c4485f22 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-paywall/platforms/react-native.md @@ -0,0 +1,112 @@ +# revenuecat-paywall: React Native + +Paywalls ship in a separate package, `react-native-purchases-ui`, that must be added alongside `react-native-purchases`. + +## Install + +The npm install commands below resolve the current latest at install time, so no version pin is needed in this skill. To verify the installed version after install, check `package.json`. The full release history lives at . + +### Bare React Native + +```bash +npm install react-native-purchases-ui +cd ios && pod install && cd .. +``` + +### Expo + +```bash +npx expo install react-native-purchases-ui +``` + +`react-native-purchases-ui` links native code (`RevenueCatUI` on iOS, `purchases-ui` Compose on Android). It will not work in Expo Go. Use a development build via `npx expo prebuild` (bare) or `eas build --profile development`. + +Deployment targets: iOS 15+, Android minSdk 24 (Compose requirement on the native side). + +## Implement + +Two APIs: the imperative `RevenueCatUI.presentPaywall(...)` / `presentPaywallIfNeeded(...)` methods, and the declarative `` component. Prefer the imperative one for one shot presentations. Use the component to embed the paywall inside a React Native screen. + +### Imperative: `presentPaywall` + +```tsx +import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui'; + +async function openPaywall() { + const result = await RevenueCatUI.presentPaywall({ + displayCloseButton: true, + }); + + switch (result) { + case PAYWALL_RESULT.PURCHASED: + case PAYWALL_RESULT.RESTORED: + // entitlement granted + break; + case PAYWALL_RESULT.CANCELLED: + // user dismissed + break; + case PAYWALL_RESULT.ERROR: + case PAYWALL_RESULT.NOT_PRESENTED: + break; + } +} +``` + +### Imperative: `presentPaywallIfNeeded` + +```tsx +const result = await RevenueCatUI.presentPaywallIfNeeded({ + requiredEntitlementIdentifier: 'premium', + displayCloseButton: true, +}); + +if ( + result === PAYWALL_RESULT.PURCHASED || + result === PAYWALL_RESULT.NOT_PRESENTED +) { + // user has access +} +``` + +### Declarative: `` component + +```tsx +import { View } from 'react-native'; +import RevenueCatUI from 'react-native-purchases-ui'; + +export function PremiumScreen({ navigation }) { + return ( + + { + navigation.goBack(); + }} + onDismiss={() => navigation.goBack()} + onPurchaseError={({ error }) => { + // surface the error + }} + /> + + ); +} +``` + +Target a specific offering by passing `options={{ offering, displayCloseButton: true }}`, where `offering` comes from `await Purchases.getOfferings()`. + +## Notes + +- `react-native-purchases-ui` is iOS + Android only. There is no web or desktop target. +- Do not call `Purchases.purchasePackage(...)` inside the paywall callbacks. The paywall drives the purchase itself. +- The component `` is a native view host. It needs a non zero size. Wrap it in a `` or give it an explicit height. +- `displayCloseButton` only affects original template paywalls. V2 Paywalls render their own close button. +- The default import is a class (`RevenueCatUI`) whose static methods (`presentPaywall`, `presentPaywallIfNeeded`, `presentCustomerCenter`) and static components (`Paywall`, `CustomerCenterView`) are used directly. + +## Verify + +Run the app on a device or simulator with a sandbox account: + +1. Trigger the paywall code path. The dashboard configured template renders. +2. Purchase a package. Either `presentPaywall` resolves to `PAYWALL_RESULT.PURCHASED` or the component's `onPurchaseCompleted` fires with a non-null `customerInfo`. +3. Close without purchasing. Either the promise resolves to `PAYWALL_RESULT.CANCELLED` or `onDismiss` fires. +4. After a successful purchase, call `Purchases.getCustomerInfo()` and confirm `customerInfo.entitlements.active['premium']` is defined. diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/SKILL.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/SKILL.md new file mode 100644 index 00000000..fdaa20b9 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/SKILL.md @@ -0,0 +1,50 @@ +--- +name: revenuecat-purchase-flow +description: Implement the RevenueCat purchase and restore flow. Use when the user asks to buy a package, purchase a subscription, fetch offerings, build paywall purchase logic, handle purchase errors, detect user cancelled, or restore previous purchases on iOS, Android, Kotlin Multiplatform, Flutter, or React Native. +--- + +# revenuecat-purchase-flow: buy a package and restore purchases + +Use this skill when the user wants to complete the purchase side of RevenueCat: fetch offerings, call `purchase`, deal with cancellation and errors, and expose a "Restore" action. It does not cover rendering a paywall UI (that lives in `revenuecat-paywall`) or gating features (that lives in `revenuecat-entitlements-gate`). + +## 1. Detect the platform + +Inspect the working directory and pick the first match, from top to bottom: + +1. React Native: `package.json` has a `react-native-purchases` entry, or `react-native` as a dependency -> read `platforms/react-native.md`. If `expo` is also a dependency, note it as an Expo project. +2. Flutter: `pubspec.yaml` exists at the project root -> read `platforms/flutter.md`. +3. Kotlin Multiplatform: `build.gradle.kts` contains a `kotlin { ... }` multiplatform source sets block, or depends on `com.revenuecat.purchases:purchases-kmp*` -> read `platforms/kmp.md`. +4. Android (native): `build.gradle(.kts)` applies `com.android.application` (and is not KMP) -> read `platforms/android.md`. +5. iOS (native): `Package.swift`, `*.xcodeproj`, `*.xcworkspace`, or `Podfile` at the project root -> read `platforms/ios.md`. + +If several match (e.g. an `ios/` folder inside a Flutter project), pick the outermost project, the one that owns the build. If still ambiguous, ask the user which platform they want to configure. + +## 2. Shared concepts (all platforms) + +- Flow. Call `getOfferings()`, pick a `Package` from the current offering, call `purchase(package)`. When it completes successfully, the returned `customerInfo` already reflects the purchase. Read `customerInfo.entitlements.active[""]` to confirm access. +- User cancellation is not an application error. Each SDK surfaces it differently: iOS throws a `purchaseCancelledError` code, Android throws a `PurchasesException` with `PurchasesErrorCode.PurchaseCancelledError`, Flutter surfaces a `PlatformException` with that same code, React Native sets `e.userCancelled === true`. Return silently in this case. Do not show an alert. +- Errors worth messaging. Payment declined, network errors, store unavailable, receipt already in use. Everything else should be logged and let the user try again. Never silently succeed when the purchase actually failed. +- Do not unlock content inside the purchase callback. Refresh customer info and let your entitlements listener (see `revenuecat-entitlements-gate`) flip the gated UI. This keeps one source of truth for access and avoids drift between the purchase path and the restore path. +- `restorePurchases()` is a user action, not an automatic step. It asks the store for the current receipt and syncs it to RevenueCat. Expose it from a visible "Restore purchases" button on the paywall and/or settings screen. Legal requirements on iOS mandate such a button. +- One purchase at a time. Disable the paywall buy buttons while a purchase is in flight to prevent double charges. + +## 3. Implementation + +Read the platform file that matches detection: + +- `platforms/ios.md` +- `platforms/android.md` +- `platforms/kmp.md` +- `platforms/flutter.md` +- `platforms/react-native.md` + +Each platform file contains a complete purchase function and a restore function. + +## 4. Verify + +Do not claim the flow works until: + +1. A sandbox purchase of the current offering's package succeeds end to end, and the user's entitlement flips to active. +2. Cancelling the store sheet does not show an error alert and does not leave the UI in a loading state. +3. A second purchase attempt for the same active subscription is handled cleanly (StoreKit / Play Billing will surface a `productAlreadyPurchased` / `receiptAlreadyInUse` path; the flow should not crash). +4. The restore button, on a fresh install signed in to the same store account, restores the entitlement and updates the UI. diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/android.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/android.md new file mode 100644 index 00000000..11822e82 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/android.md @@ -0,0 +1,120 @@ +# revenuecat-purchase-flow: Android (native Kotlin) + +## Fetch offerings + +```kotlin +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.awaitOfferings +import com.revenuecat.purchases.models.Package + +suspend fun currentPackages(): List { + val offerings = Purchases.sharedInstance.awaitOfferings() + return offerings.current?.availablePackages.orEmpty() +} +``` + +`offerings.current` reflects the current offering configured in the RevenueCat dashboard. + +## Purchase a package + +`awaitPurchase` needs a `PurchaseParams` built with the launching `Activity` and the `Package`. It throws a `PurchasesException` whose `error.code` can be compared against `PurchasesErrorCode`. + +```kotlin +import android.app.Activity +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.awaitPurchase +import com.revenuecat.purchases.models.Package + +sealed interface PurchaseOutcome { + data object Purchased : PurchaseOutcome + data object Cancelled : PurchaseOutcome + data class Failed(val error: Throwable) : PurchaseOutcome +} + +suspend fun buy(activity: Activity, pkg: Package): PurchaseOutcome = try { + val params = PurchaseParams.Builder(activity, pkg).build() + Purchases.sharedInstance.awaitPurchase(params) + // Do not unlock content here. The updatedCustomerInfoListener flips the + // gated UI (see revenuecat-entitlements-gate). + PurchaseOutcome.Purchased +} catch (e: PurchasesException) { + if (e.code == PurchasesErrorCode.PurchaseCancelledError) { + PurchaseOutcome.Cancelled + } else { + PurchaseOutcome.Failed(e) + } +} +``` + +`awaitPurchase` returns a `PurchaseResult` (storeTransaction + customerInfo) on success; you usually do not need either if you are already listening to `updatedCustomerInfoListener`. + +## Wire it to a Compose button + +```kotlin +@Composable +fun BuyButton(pkg: Package) { + val activity = LocalContext.current as Activity + val scope = rememberCoroutineScope() + var isBuying by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + Button( + enabled = !isBuying, + onClick = { + scope.launch { + isBuying = true + try { + when (val outcome = buy(activity, pkg)) { + is PurchaseOutcome.Purchased, + is PurchaseOutcome.Cancelled -> Unit + is PurchaseOutcome.Failed -> + errorMessage = outcome.error.message + } + } finally { + isBuying = false + } + } + } + ) { + Text(pkg.product.price.formatted) + } + + errorMessage?.let { msg -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + confirmButton = { TextButton({ errorMessage = null }) { Text("OK") } }, + title = { Text("Purchase failed") }, + text = { Text(msg) } + ) + } +} +``` + +## Restore purchases + +```kotlin +import com.revenuecat.purchases.awaitRestore + +suspend fun restore(): Result = runCatching { + Purchases.sharedInstance.awaitRestore() +} +``` + +Expose this from a visible "Restore purchases" button on the paywall and/or settings screen. + +## Notes + +- `awaitPurchase`, `awaitOfferings`, `awaitRestore`, and `awaitCustomerInfo` live in `com.revenuecat.purchases.*` as extensions on `Purchases`. They throw `PurchasesException`. +- `PurchaseParams.Builder` must receive the currently visible `Activity`, not the `Application` or a detached context. Google Play needs a surface to attach its billing sheet. +- The callback variants (`purchase(PurchaseParams, PurchaseCallback)`) expose `userCancelled: Boolean` directly in `onError`. Use them if you do not want the coroutine dependency. +- Kotlin enum comparison uses `==` on `PurchasesErrorCode`. `e.code` returns the enum value. + +## Verify + +1. A sandbox purchase of a package flips the "premium" entitlement to active, observed via `updatedCustomerInfoListener`. +2. Tapping the Play Billing sheet's back button returns to the app without an error dialog. +3. On a fresh install signed into the same Google account, "Restore purchases" re-grants the entitlement. +4. `adb logcat | grep Purchases` shows the purchase lifecycle. An `InvalidCredentialsError` means the API key does not match the app's package name in the dashboard. diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/flutter.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/flutter.md new file mode 100644 index 00000000..5d079f1d --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/flutter.md @@ -0,0 +1,120 @@ +# revenuecat-purchase-flow: Flutter + +## Fetch offerings + +```dart +import 'package:purchases_flutter/purchases_flutter.dart'; + +Future> currentPackages() async { + final offerings = await Purchases.getOfferings(); + return offerings.current?.availablePackages ?? const []; +} +``` + +## Purchase a package + +`Purchases.purchasePackage` throws a `PlatformException` on failure. Use `PurchasesErrorHelper.getErrorCode(e)` to detect cancellation. + +```dart +import 'package:flutter/services.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +sealed class PurchaseOutcome {} +class Purchased extends PurchaseOutcome {} +class Cancelled extends PurchaseOutcome {} +class Failed extends PurchaseOutcome { + final Object error; + Failed(this.error); +} + +Future buy(Package pkg) async { + try { + await Purchases.purchasePackage(pkg); + // Do not unlock content here. A CustomerInfoUpdateListener flips the + // gated UI (see revenuecat-entitlements-gate). + return Purchased(); + } on PlatformException catch (e) { + final code = PurchasesErrorHelper.getErrorCode(e); + if (code == PurchasesErrorCode.purchaseCancelledError) { + return Cancelled(); + } + return Failed(e); + } +} +``` + +## Wire it to a widget + +```dart +class BuyButton extends StatefulWidget { + final Package package; + const BuyButton(this.package, {super.key}); + + @override + State createState() => _BuyButtonState(); +} + +class _BuyButtonState extends State { + bool _isBuying = false; + + Future _tap() async { + setState(() => _isBuying = true); + try { + final outcome = await buy(widget.package); + if (!mounted) return; + if (outcome is Failed) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Purchase failed'), + content: Text(outcome.error.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + } finally { + if (mounted) setState(() => _isBuying = false); + } + } + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: _isBuying ? null : _tap, + child: Text(widget.package.storeProduct.priceString), + ); + } +} +``` + +## Restore purchases + +```dart +Future restore() async { + try { + return await Purchases.restorePurchases(); + } on PlatformException { + return null; + } +} +``` + +Expose this from a visible "Restore purchases" button on the paywall and/or settings screen. + +## Notes + +- `PurchasesErrorHelper.getErrorCode(e)` returns a `PurchasesErrorCode` enum. Compare with `==` against `PurchasesErrorCode.purchaseCancelledError` and friends. This is the supported way and survives plugin version bumps. +- `Purchases.purchase(PurchaseParams.package(pkg))` is the newer overload and accepts promotional offers and other options. `purchasePackage(pkg)` remains the simplest call for the common case. +- Do not read `PlatformException.code` strings directly. The underlying native error strings differ between iOS and Android. + +## Verify + +1. A sandbox purchase flips the "premium" entitlement to active and the listener notifies the UI. +2. Cancelling the native sheet returns `Cancelled` and no error dialog appears. +3. On a fresh install signed into the same store account, "Restore purchases" re-grants access. +4. `flutter logs` (or `adb logcat` / Xcode console) shows the `Purchases` SDK logs through the transaction. diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/ios.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/ios.md new file mode 100644 index 00000000..46c66d6e --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/ios.md @@ -0,0 +1,101 @@ +# revenuecat-purchase-flow: iOS (native) + +## Fetch offerings + +```swift +import RevenueCat + +func currentPackages() async throws -> [Package] { + let offerings = try await Purchases.shared.getOfferings() + guard let current = offerings.current else { return [] } + return current.availablePackages +} +``` + +`offerings.current` reflects the current offering configured in the RevenueCat dashboard. If it is `nil`, no packages are live for this user. + +## Purchase a package + +`Purchases.shared.purchase(package:)` returns `PurchaseResultData` (a tuple of `transaction`, `customerInfo`, `userCancelled`). User cancellation is also surfaced as a thrown `ErrorCode.purchaseCancelledError`. Handle both to be safe. + +```swift +import RevenueCat + +enum PurchaseOutcome { + case purchased + case cancelled + case failed(Error) +} + +func buy(_ package: Package) async -> PurchaseOutcome { + do { + let result = try await Purchases.shared.purchase(package: package) + if result.userCancelled { return .cancelled } + // Do not unlock content here. The entitlements listener observes + // customerInfo and flips the gated UI. + return .purchased + } catch { + let nsError = error as NSError + if nsError.code == ErrorCode.purchaseCancelledError.rawValue { + return .cancelled + } + return .failed(error) + } +} +``` + +## Wire it to a SwiftUI button + +```swift +struct BuyButton: View { + let package: Package + @State private var isBuying = false + @State private var errorMessage: String? + + var body: some View { + Button(package.storeProduct.localizedPriceString) { + Task { + isBuying = true + defer { isBuying = false } + switch await buy(package) { + case .purchased, .cancelled: + break + case .failed(let error): + errorMessage = (error as NSError).localizedDescription + } + } + } + .disabled(isBuying) + .alert("Purchase failed", isPresented: .constant(errorMessage != nil)) { + Button("OK") { errorMessage = nil } + } message: { Text(errorMessage ?? "") } + } +} +``` + +## Restore purchases + +```swift +func restore() async -> Result { + do { + let info = try await Purchases.shared.restorePurchases() + return .success(info) + } catch { + return .failure(error) + } +} +``` + +Surface this from a visible "Restore purchases" button in the paywall and/or settings. After it returns, check `info.entitlements.active["premium"]` if you want to message "nothing to restore". + +## Notes + +- `purchase(package:)` throws on iOS 13+ via `async`. For older targets, the completion variant `purchase(package:completion:)` delivers `(transaction, customerInfo, error, userCancelled)`. +- `ErrorCode` is a Swift enum conforming to `Error`. Because the SDK throws a `PublicError` (an `NSError`), compare against `ErrorCode..rawValue` on the `NSError.code` instead of casting with `as?`. +- `offerings.current` reflects the current offering for this user. Targeting rules in the dashboard can change it between users. + +## Verify + +1. A sandbox purchase of a package flips the "premium" entitlement to active within a few seconds. +2. Tapping "Cancel" on the StoreKit sheet returns without an error alert and re-enables the buy button. +3. A fresh install signed in to the same sandbox Apple ID can restore via "Restore purchases" and regain access. diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/kmp.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/kmp.md new file mode 100644 index 00000000..425f0c97 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/kmp.md @@ -0,0 +1,76 @@ +# revenuecat-purchase-flow: Kotlin Multiplatform + +`purchases-kmp` mirrors the native SDKs. Coroutine extensions live in `com.revenuecat.purchases.kmp.ktx` and work identically on both targets. + +## Fetch offerings (commonMain) + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitOfferings +import com.revenuecat.purchases.kmp.models.Package + +suspend fun currentPackages(): List { + val offerings = Purchases.sharedInstance.awaitOfferings() + return offerings.current?.availablePackages.orEmpty() +} +``` + +## Purchase a package (commonMain) + +`awaitPurchase(package)` throws `PurchasesTransactionException`, which carries both the underlying `PurchasesError` and a `userCancelled: Boolean`. Check `userCancelled` first, then fall back to the error. + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitPurchase +import com.revenuecat.purchases.kmp.models.Package +import com.revenuecat.purchases.kmp.models.PurchasesTransactionException + +sealed interface PurchaseOutcome { + data object Purchased : PurchaseOutcome + data object Cancelled : PurchaseOutcome + data class Failed(val error: Throwable) : PurchaseOutcome +} + +suspend fun buy(pkg: Package): PurchaseOutcome = try { + Purchases.sharedInstance.awaitPurchase(pkg) + // Do not unlock content here. A `PurchasesDelegate.onCustomerInfoUpdated` + // observer flips the gated UI (see revenuecat-entitlements-gate). + PurchaseOutcome.Purchased +} catch (e: PurchasesTransactionException) { + if (e.userCancelled) PurchaseOutcome.Cancelled + else PurchaseOutcome.Failed(e) +} +``` + +On Android, Google Play requires an `Activity` to host the billing sheet. The KMP SDK takes the foreground activity from the platform `actual` on Android automatically in most versions. If your installed version requires you to pass one explicitly, prefer what the IDE autocompletes and see the purchases-kmp README. + +## Restore purchases (commonMain) + +```kotlin +import com.revenuecat.purchases.kmp.Purchases +import com.revenuecat.purchases.kmp.ktx.awaitRestore +import com.revenuecat.purchases.kmp.models.CustomerInfo + +suspend fun restore(): Result = runCatching { + Purchases.sharedInstance.awaitRestore() +} +``` + +Expose this from a visible "Restore purchases" button on each platform's paywall / settings screen. + +## Callback variants + +If you do not want the coroutine dependency, every suspending extension has a callback counterpart on `Purchases.sharedInstance`: `getOfferings(onError, onSuccess)`, `purchase(packageToPurchase, onError, onSuccess)` (where `onError` takes `(PurchasesError, userCancelled: Boolean)`), and `restorePurchases(onError, onSuccess)`. + +## Notes + +- `PurchasesTransactionException` is specific to transactional calls and exposes `userCancelled`. Non transactional calls (`awaitCustomerInfo`, `awaitOfferings`, `awaitRestore`, `awaitLogIn`, `awaitLogOut`) throw the plain `PurchasesException`. +- Imports live under `com.revenuecat.purchases.kmp.ktx.*` for coroutines and `com.revenuecat.purchases.kmp.models.*` for types. +- Error constants are on `com.revenuecat.purchases.kmp.models.PurchasesErrorCode` if you need to branch on specific non cancellation errors. + +## Verify + +1. On each target (iOS + Android) a sandbox purchase of the current offering's first package flips the premium entitlement to active. +2. Cancelling the store sheet lands in the `Cancelled` branch on both platforms without showing an error dialog. +3. "Restore purchases" on a fresh install restores the entitlement on whichever store the user is signed in to. +4. `Purchases` logs appear in the native console of each platform (Xcode console on iOS, logcat on Android). diff --git a/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/react-native.md b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/react-native.md new file mode 100644 index 00000000..e6921789 --- /dev/null +++ b/plugins/revenuecat/skills/revenuecat-purchase-flow/platforms/react-native.md @@ -0,0 +1,97 @@ +# revenuecat-purchase-flow: React Native + +## Fetch offerings + +```ts +import Purchases, { PurchasesPackage } from 'react-native-purchases'; + +export async function currentPackages(): Promise { + const offerings = await Purchases.getOfferings(); + return offerings.current?.availablePackages ?? []; +} +``` + +## Purchase a package + +`Purchases.purchasePackage` rejects on failure. The SDK sets `error.userCancelled === true` when the user dismisses the store sheet. Check that before treating anything as an error. + +```ts +import Purchases, { PurchasesPackage } from 'react-native-purchases'; + +export type PurchaseOutcome = + | { kind: 'purchased' } + | { kind: 'cancelled' } + | { kind: 'failed'; error: unknown }; + +export async function buy(pkg: PurchasesPackage): Promise { + try { + await Purchases.purchasePackage(pkg); + // Do not unlock content here. A CustomerInfoUpdateListener flips the + // gated UI (see revenuecat-entitlements-gate). + return { kind: 'purchased' }; + } catch (e: any) { + if (e?.userCancelled === true) return { kind: 'cancelled' }; + return { kind: 'failed', error: e }; + } +} +``` + +If you prefer an explicit error code comparison, `Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR` is also set on `e.code` when the user cancels. + +## Wire it to a component + +```tsx +import React, { useState } from 'react'; +import { Alert, Button } from 'react-native'; +import type { PurchasesPackage } from 'react-native-purchases'; +import { buy } from './buy'; + +export function BuyButton({ package: pkg }: { package: PurchasesPackage }) { + const [isBuying, setIsBuying] = useState(false); + + const onPress = async () => { + setIsBuying(true); + const outcome = await buy(pkg); + setIsBuying(false); + if (outcome.kind === 'failed') { + Alert.alert('Purchase failed', String((outcome.error as any)?.message ?? outcome.error)); + } + }; + + return ( +