# `revenuecat`

| Package | Kind | Provides the role | Needs | Choose with |
| --- | --- | --- | --- | --- |
| [`smf_revenuecat`](https://pub.dev/packages/smf_revenuecat) | infrastructure | Purchases | values in `.env`, such as [`dart_define`](https://doc.saymyframe.com/modules/dart-define.md) | `-m revenuecat` |

`revenuecat` sells the premium access of the app with [RevenueCat](https://www.revenuecat.com), through the [purchases_flutter](https://pub.dev/packages/purchases_flutter) package. The user buys a plan of the default offering of a RevenueCat project, such as a monthly or a yearly subscription or a payment once, and restores it on another device. The app knows whether the user has premium access before its first frame.

The code of the app never uses the package. It uses `appPurchases`, which the purchases role generates; see [Purchases](https://doc.saymyframe.com/guides/purchases.md). To show the plans to the user, add [`paywall`](https://doc.saymyframe.com/modules/paywall.md), which brings this module with it.

## What it adds to the app

| In the app | What it is |
| --- | --- |
| `purchases_flutter: ^10.18.0` in the dependencies | The SDK of RevenueCat. |
| `lib/core/purchases/revenuecat_purchases_service.dart` | `RevenueCatPurchasesService`, the purchases service of the app on `Purchases`; `createRevenueCatPurchasesService()`, which creates it; and `revenueCatKey()`, which chooses the API key for the platform and the build. |
| Three keys in `.env` | `REVENUECAT_APPLE_KEY`, `REVENUECAT_GOOGLE_KEY` and `REVENUECAT_TEST_KEY`, the public API keys of RevenueCat, through the role of the values in `.env`. |
| The section "RevenueCat" of the README | The way to a first purchase in the Test Store of RevenueCat, what a release needs, which key a build takes, and what the app does without a key. |

Whichever module provides it, the purchases role adds these files:

| File | What it holds |
| --- | --- |
| `lib/core/purchases/app_purchases.dart` | `appPurchases`, through which the code of the app reads the access of the user, gets the plans, buys and restores, and `initPurchases()`. It exports the types of the answers, so the code of the app imports this file alone. |
| `lib/core/purchases/purchases_service.dart` | `PurchasesService`, the interface that this module implements, with `AppAccess`, `Plan`, `IntroOffer`, `BillingPeriod` and `PurchasesFailure`. |
| `lib/core/purchases/restore_purchases_setting.dart`, in an app with a [settings screen](https://doc.saymyframe.com/modules/settings.md) | `RestorePurchasesSetting`, the row of the settings screen that restores purchases and tells what it found. |

`bootstrap()` awaits `initPurchases()` in its platform phase, which creates the service and starts `appPurchases` with it. This is `bootstrap()` of an app with this module and `dart_define`, which comes with it:

```dart title="lib/bootstrap.dart"
Future<void> bootstrap() async {
  await initPurchases();
}
```

In an app with sign-in, `initAuth()` comes first, so the purchases belong to the user who is signed in; see [Purchases](https://doc.saymyframe.com/guides/purchases.md#whose-purchases-they-are).

The module needs nothing in the native projects. The Billing Library of Google Play declares its permission itself, and the In-App Purchase capability of iOS has no entitlement.

In the [guide for coding agents](https://doc.saymyframe.com/guides/coding-agents.md) of the app, the purchases role describes `appPurchases` under "Purchases". The module adds which file is on purchases_flutter, that the premium access is the entitlement `premium`, which key a build takes and why that rule stays, that nothing calls RevenueCat before it is configured, how a grant made on the RevenueCat dashboard reaches the app, and what a test answers in place of the platform side of the package.

## The premium access in RevenueCat

The premium access of the app is the entitlement `premium` in RevenueCat. Every product of the current offering unlocks it, so attach each product to `premium`. A plan of the app is a package of the current offering, which the RevenueCat dashboard calls the default offering, and the id of a `Plan` is the identifier of its package. The app shows the plans in the order of the offering.

| In RevenueCat | In the app |
| --- | --- |
| The entitlement `premium`, active | `PremiumAccess`, with `expiresAt` from its expiration date (`null` for a payment once), `renews`, `inTrial` and the `manageUrl` of the customer |
| No active `premium` | `NoAccess` |
| A package of the current offering | A `Plan`, with the price, the period and the intro offer of its product in the store |
| No current offering, or one without packages | `plans()` fails with `notConfigured`, and its hint tells what to make in RevenueCat |

The steps by hand of the module set this up; see [Finish the setup](#finish-the-setup).

## The API keys

RevenueCat has a public API key for each app of a project: the App Store app, the Play Store app, and the Test Store, a store of RevenueCat itself in which you buy without an account at Apple or Google. They go into `.env`:

```bash title=".env"
REVENUECAT_APPLE_KEY=appl_...
REVENUECAT_GOOGLE_KEY=goog_...
REVENUECAT_TEST_KEY=test_...
```

`revenueCatKey()` chooses the key that a build configures RevenueCat with:

| Build | Key |
| --- | --- |
| A debug build, with `REVENUECAT_TEST_KEY` filled in | `REVENUECAT_TEST_KEY`, the Test Store |
| A debug build without it, and every profile or release build | `REVENUECAT_APPLE_KEY` on iOS, `REVENUECAT_GOOGLE_KEY` on Android |

A profile or release build never takes the key of the Test Store: RevenueCat stops such a build on purpose. So to try the sandbox of a store in a debug build, leave `REVENUECAT_TEST_KEY` empty.

The keys are public. Each is compiled into the app, where anyone can read it, as with every value of `.env`. A secret API key of RevenueCat, which starts with `sk_`, never goes into the app, and `revenueCatKey()` refuses one.

While the key that a build takes has no value, the app runs without purchases. It never calls RevenueCat, `appPurchases.value` stays `NoAccess`, every call fails with the reason `notConfigured`, and a debug build prints which key to fill in. The same goes for a secret key, a Test Store key in a profile or release build, and a platform other than Android and iOS.

## What the service adds to the package

The purchases role promises more than purchases_flutter does in a few places, and the service makes up the difference:

| What the service does | Why |
| --- | --- |
| It configures RevenueCat when it is created, and calls nothing else of RevenueCat before that has completed. Without a key, it calls nothing at all. | iOS stops an app that calls RevenueCat before it is configured. |
| A configuration that fails, also for want of the platform side of the plugin (`MissingPluginException`), leaves the service not configured, and every call fails with `notConfigured` and a hint. | The app starts and runs without purchases rather than crash. |
| `identify` logs a user of the app in with `logIn`, and logs out for the user of the device. A log out of an anonymous user, which RevenueCat fails with the code 22, counts as done. | The role says whose the purchases are, and the user of the device is the anonymous user of RevenueCat. |
| It reads the code of an error with `int.tryParse`. | An error of the plugin itself has a code that is no number, as on Android before the first configuration. |
| A purchase that RevenueCat gives no active entitlement `premium` for fails with `notConfigured`, with a hint that names the product. | Every plan gives premium access, so such a product is not attached to `premium` yet. A restore gives the access once it is. |
| On iOS, a plan has its intro offer only when iOS says that the user may take it. | A user who had a trial of the group gets no second one, and the paywall would promise one. |
| On Android, a plan has the title of its product without the name of the app. | Google Play appends the name of the app to the title. |
| The access of a plan that renews takes its period from the offerings, which it waits for at most a second. | The customer info of RevenueCat has no period. The offerings come at once from what the device keeps, so the wait only ends a read that would wait for the network: the access then has no period, and a later access has it. |

Each error of RevenueCat becomes a reason of the role:

| Reason | Codes of RevenueCat |
| --- | --- |
| `pending` | `paymentPendingError` |
| `network` | `networkError`, `offlineConnectionError`, `productRequestTimeout`, `apiEndpointBlocked` |
| `alreadyOwned` | `productAlreadyPurchasedError`, `receiptAlreadyInUseError`, `receiptInUseByOtherSubscriberError`, `purchaseBelongsToOtherUser` |
| `notAllowed` | `purchaseNotAllowedError` |
| `unavailable` | `productNotAvailableForPurchaseError`, `purchaseInvalidError`, `ineligibleError` |
| `notConfigured` | `configurationError`, `invalidCredentialsError`, `invalidAppleSubscriptionKeyError`, `insufficientPermissionsError` |
| `storeProblem` | `storeProblemError`, `invalidReceiptError`, `missingReceiptFileError`, `unexpectedBackendResponseError`, `unknownBackendError`, `customerInfoError`, `systemInfoError`, `signatureVerificationFailed`, `testStoreSimulatedPurchaseError` |
| `unknown` | Any other code, and an error that is not of RevenueCat |

A cancel is no failure: `appPurchases.purchase()` completes with `false`. The `developerHint` of a failure has the code and the message of RevenueCat. For an API key that RevenueCat refuses, it names the key of `.env` to fix.

RevenueCat answers from a cache of the customer info. An entitlement that you grant on the RevenueCat dashboard, or with its API, reaches the app once the cache is emptied with `Purchases.invalidateCustomerInfoCache()` and the customer info is read again, or once the SDK refreshes it itself. `appPurchases` asks for the access again each time the user comes back to the app.

## Finish the setup

Right after `smf create` the keys are empty, so the app runs without purchases. To buy a plan in a debug build with nothing but a RevenueCat account:

1. Create a project in RevenueCat. A new project has a Test Store with products.
2. Create the entitlement `premium` and attach the products to it.
3. Add the packages of those products to an offering, and make it the default one.
4. Copy the public API key of the Test Store, which starts with `test_`, into `REVENUECAT_TEST_KEY` in `.env`.
5. Run the app with `flutter run --dart-define-from-file=.env` and buy a plan. The Test Store shows a dialog of its own, where "Test valid purchase" completes the purchase.

For a release, the app sells in the App Store and in Google Play, and RevenueCat checks each purchase with the store. That takes the steps by hand of the app, which the README lists in its section "Finish the setup". The table links to the click-by-click guide of each:

| Step | What you do |
| --- | --- |
| [`purchases.store_agreements`](https://doc.saymyframe.com/setup/purchases-store-agreements.md) | Sign the agreement for paid apps in App Store Connect, and set up a payments profile in Google Play Console. |
| [`purchases.store_products`](https://doc.saymyframe.com/setup/purchases-store-products.md) | Create the products of the plans in both stores. |
| [`revenuecat.entitlement_and_offering`](https://doc.saymyframe.com/setup/revenuecat-entitlement-and-offering.md) | Create the entitlement `premium` and the default offering with a package for each plan. On Google Play a lifetime plan is a one-time product: give it the type Non-consumable in RevenueCat, or RevenueCat consumes the purchase and the user can buy the plan again. |
| [`revenuecat.api_keys`](https://doc.saymyframe.com/setup/revenuecat-api-keys.md) | Add the App Store app and the Play Store app to the project, and copy the public API keys of the three apps into `.env`. |
| [`revenuecat.app_store_key`](https://doc.saymyframe.com/setup/revenuecat-app-store-key.md) | Give RevenueCat the In-App Purchase key of App Store Connect, with which it checks the purchases on iOS. |
| [`revenuecat.play_service_account`](https://doc.saymyframe.com/setup/revenuecat-play-service-account.md) | Give RevenueCat a service account with access to Google Play, with which it checks the purchases on Android. |

The first two are steps of the purchases role, which every provider that sells in the stores needs. An app with [`paywall`](https://doc.saymyframe.com/modules/paywall.md) has one more, for the pages of its terms of use and its privacy policy.

## What the module does not do

- It sells the packages of the default offering alone, and the one entitlement `premium`. Other offerings and other entitlements of the project do not reach the app.
- It adds no paywall of RevenueCat (`purchases_ui_flutter`). The screens that sell come from [`paywall`](https://doc.saymyframe.com/modules/paywall.md) or from your own code, through `appPurchases`.
- It sells on Android and iOS only.
- It restores nothing on its own. The app restores where the user asks for it, such as on the paywall or in the row of the settings screen.
- It grants nothing from a server. A grant made on the dashboard reaches the app as described above.

## In tests

purchases_flutter has no platform side for tests and no platform interface: `Purchases` is a class of static methods over the method channel `purchases_flutter`. A test that runs `main()` or `bootstrap()` answers that channel itself, with `setMockMethodCallHandler` of `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger`. The listeners and the last customer info of `Purchases` are static, so they last for a whole test file. The guide for coding agents of the app says the same. A test of a screen needs none of this: it gives `appPurchases.start()` a service of its own; see [Purchases](https://doc.saymyframe.com/guides/purchases.md#in-tests).

Whichever module provides the purchases, a fresh user who buys a plan has premium access, the access stays at the next start of the app, and the purchases of each user stay apart. SMF's CI checks this in running apps with the role, over a stand-in for RevenueCat, its backend and the store account of a device, which starts as on a first launch. For this module, it checks the key that each build takes and that the app reads the keys from `.env`; that without a key, or without the platform side of the plugin, the service calls nothing and fails every call with a hint; what each call sends to RevenueCat; the reason of each code of RevenueCat; and the plans and the access on Android and on iOS, from a purchase, from the customer info and from the next launch. On a device, it checks that RevenueCat is configured exactly when the app has a key, and that the access and the plans of the app are those that RevenueCat gives. See [Contributing](https://doc.saymyframe.com/contributing.md#the-roles-at-run-time).

## Choosing it

To choose it without the question of `smf create`:

```bash
smf create my_app -m revenuecat
```

The module requires the values of `.env`, and `smf create` adds the only module that provides them:

```text
Adding flutter_core: the only provider of the app entry role, which every app needs.
Adding dart_define: the only provider of the env role, which revenuecat requires.
```

[`paywall`](https://doc.saymyframe.com/modules/paywall.md) requires the purchases role, so with it `smf create` adds this module without a question while it is the only one that provides the purchases.
