# Purchases

A generated app can sell premium access: the user buys a plan, such as a monthly or a yearly subscription or a payment once, and every plan gives the same access. It has two parts, each from a module of its own:

| Part | Role | Module | What it brings |
| --- | --- | --- | --- |
| The purchases | Purchases | [`revenuecat`](https://doc.saymyframe.com/modules/revenuecat.md) | `appPurchases`: whether the user has premium access, known before the first frame, the plans of the current offer, a purchase and a restore. |
| The screens | none | [`paywall`](https://doc.saymyframe.com/modules/paywall.md) | The paywall, the screen of the subscription, the guard that shows the paywall to a user without the access, a row of the settings screen and a step of the onboarding. |

`paywall` requires the purchases role, so it brings `revenuecat` while that module is the only one that provides it, and `dart_define` for the API keys of RevenueCat in `.env`:

```bash
smf create my_app -m home,onboarding,settings,bottom_tabs,paywall,bloc
```

An app can also have the purchases without the paywall, with `-m revenuecat`, and screens of your own.

| File | From | What it holds |
| --- | --- | --- |
| `lib/core/purchases/app_purchases.dart` | the purchases role | `appPurchases`, through which code 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` | the purchases role | `PurchasesService`, the interface that the module that provides the role implements, with `AppAccess`, `Plan`, `IntroOffer`, `BillingPeriod` and `PurchasesFailure`. The code of the app does not use it. |
| `lib/core/purchases/restore_purchases_setting.dart` | the purchases role, in an app with a settings screen | `RestorePurchasesSetting`, the row of the settings screen that restores purchases. |
| `lib/core/purchases/revenuecat_purchases_service.dart` | `revenuecat` | The purchases service on RevenueCat. |

`bootstrap()` awaits `initPurchases()` in its platform phase, so the app knows the access of the user before its first frame. In an app with sign-in it comes after `initAuth()`, whatever the order of the modules, so the purchases are those of the user who is signed in. This is `bootstrap()` of an app with `revenuecat` and `firebase_auth`:

```dart title="lib/bootstrap.dart"
Future<void> bootstrap() async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  await initAuth();
  await initPurchases();
}
```

The start waits for the access at most 3 seconds, from what the device keeps. An access that comes later still counts, and the app does not wait for it. The role registers nothing in a DI container: code uses `appPurchases`, a top-level variable, and calls neither `initPurchases()` nor `createPurchasesService()`.

## The access of the user

`appPurchases.value` is the `AppAccess` of the user: `NoAccess`, or `PremiumAccess` while a plan that the user bought is active. `appPurchases.hasPremium` is a `ValueListenable<bool>` that tells which, for a widget or a guard of the routes:

```dart
import 'core/purchases/app_purchases.dart';

ValueListenableBuilder<bool>(
  valueListenable: appPurchases.hasPremium,
  builder: (context, premium, _) =>
      premium ? const ExportButton() : const UpgradeHint(),
);
```

In an app with [`paywall`](https://doc.saymyframe.com/modules/paywall.md), a widget reads the access through the state manager of the app instead, as `context.watch<PremiumCubit>().state` with BLoC or `ref.watch(premiumProvider)` with Riverpod.

`PremiumAccess` tells:

| Field | What it is |
| --- | --- |
| `expiresAt` | When the access ends, or `null` for an access that never ends, as of a plan paid once. |
| `renews` | Whether the plan renews at the end of its period. |
| `inTrial` | Whether the access is a free trial. |
| `period` | How often the plan is billed, or `null` for a plan paid once and while the provider does not know it yet. |
| `manageUrl` | The page of the store where the user manages or cancels the plan, or `null`. |

The access changes when the user buys or restores, and when the provider learns of a renewal, an expiry, a refund or a grant made elsewhere. The app asks for the access again each time the user comes back to it.

## Buy and restore

| Call | What it does |
| --- | --- |
| `appPurchases.plans()` | Gives the plans of the current offer, in the order of the provider. |
| `appPurchases.purchase(plan)` | Buys the plan. It completes with `true` once the user has premium access, and with `false` when the user cancelled, which is no failure. |
| `appPurchases.restore()` | Gives the user what the store account bought, such as on a new device, and completes with the access. |

A `Plan` has an `id`, a `title`, its `price` as the store shows it, its `amount` and `currencyCode`, its `period`, which is `null` for a plan paid once, and its `introOffer`, a free trial or a lower price at first that this user may take. A `BillingPeriod` is a `count` of a `unit`: a day, a week, a month or a year.

The calls run one after another, in the order they were made. A call fails only with a `PurchasesFailure`. Show a text of the app for its `reason`, and its `developerHint` in debug mode only:

| Reason | What it means |
| --- | --- |
| `network` | The device did not reach the store or the provider. |
| `pending` | The payment waits for an approval, as one that a parent approves. The access comes later without another call. |
| `alreadyOwned` | The store account owns the plan already: a restore gives the access. |
| `notAllowed` | The user may not pay on this device, as with parental controls. |
| `unavailable` | The store, or the plan, is not available on this device or for this store account. |
| `storeProblem` | The store failed on its side. Trying again later may help. |
| `notConfigured` | Purchases are not set up, such as a key of the provider that is missing, or an offer without plans. The `developerHint` says what is missing. |
| `unknown` | Anything else. The `developerHint` has the error. |

A purchase whose answer gives no premium access fails with `notConfigured`, whichever provider gave it, since every plan gives that access.

## Screens only for premium users

The role publishes `PurchasesRole.premium`, the condition of a route whose screen is only for a user with premium access. A route asks for it in its `conditions`, and a module that requires the role stands for it with a guard whose function returns `appPurchases.hasPremium`. [`paywall`](https://doc.saymyframe.com/modules/paywall.md) declares that guard, `paywall.hasPremium`, and its target is the paywall: a request for such a route from a user without the access opens the paywall over the page, and the router shows the route once the user has bought a plan. See [Conditions of routes](https://doc.saymyframe.com/concepts/conditions.md).

In an app without such a guard, the routes that ask for the condition show to every user, and the role warns of nothing. A screen never redirects itself.

## Restore purchases

In an app with a [settings screen](https://doc.saymyframe.com/modules/settings.md), the role gives it `RestorePurchasesSetting`, the row Restore purchases. A tap restores once and shows what the restore found:

| The restore | The row says |
| --- | --- |
| found premium access | Your premium access is back. |
| found nothing | No purchases to restore. |
| failed | Couldn't restore purchases. Try again. In a debug build, the words of the provider are below it. |

While a restore is on its way, the row shows a spinner and takes no other tap. A failure looks the same whatever its reason. A screen reader reads each result as it comes. The four texts are in English and in Ukrainian, and follow the language of an app with a module that provides the localization.

In an app without a settings screen, the screen that sells offers the restore itself, as the paywall does with its link Restore purchases.

## Whose purchases they are

Without sign-in, the purchases belong to this installation of the app. On another device, or after the app was installed again, the user restores them.

With sign-in, such as [`firebase_auth`](https://doc.saymyframe.com/modules/firebase-auth.md), `appPurchases` follows `appSession`. The purchases belong to the user of the session, by the id of the user, and to the user of this device while nobody is signed in:

| What the user does | Whose the purchases are then |
| --- | --- |
| Buys without an account, then signs up | The new account's: it takes them over from the user of this device. An anonymous user keeps them too, since the account keeps the id of that user. |
| Buys without an account, then signs in to an account that exists already | Still the user of this device's. The account gets them only through a restore. |
| Signs out | The user of this device is a new one, with nothing bought. Without a restore, the next user of the device never gets the purchases of a user who signed out. |
| Restores | The user who is signed in gets what the store account bought. The backend of the provider decides whether that moves purchases away from another user. |

The app does not restore on its own: it restores where the user asks for it. A call of `appPurchases` waits for a change of the user, and a change of the user waits for the call on its way, so a purchase belongs to the user who made it.

## What a screen that sells shows

The App Store and Google Play ask a screen that sells a plan to show:

- the name of the plan, how long a period lasts and what the user gets;
- the full price that the store charges for each period, shown more clearly than any other price on the screen, and for a yearly plan never mainly as a price per month;
- for a free trial or a lower price at first, how long it lasts and the price after it;
- that the plan renews until the user cancels, and how to cancel it in the store;
- a way to restore purchases;
- links to the terms of use and the privacy policy of the app;
- a visible way to close the screen without buying.

The [paywall](https://doc.saymyframe.com/modules/paywall.md#what-the-stores-ask-of-a-paywall) shows each of these. A screen of your own shows them too.

## While purchases are not set up

Until the provider has what it needs, such as its API keys, the app runs without purchases. `appPurchases.value` stays `NoAccess`, every call fails with the reason `notConfigured`, and a debug build prints its hint once, which names what is missing.

An error that no caller sees, such as one of the start or of a return to the app, changes nothing. `network`, `unavailable` and `storeProblem` say nothing then, so a store that does not answer reaches no crash report. Any other error reaches the handlers of the uncaught errors of the app.

## Steps by hand

Selling in the stores takes steps that no command can do: agreements and products in App Store Connect and Google Play Console, and the setup of the provider. The purchases role lists the two steps in the stores, which every provider that sells there needs, and the provider lists its own. The README of the app lists them in its section "Finish the setup", and `smf create` prints the link to their guides. The guides of [the payments](https://doc.saymyframe.com/setup/purchases-store-agreements.md) and of [the products](https://doc.saymyframe.com/setup/purchases-store-products.md) show the stores click by click.

## For a provider

A module that provides the role implements `PurchasesService` and contributes the implementation as data of the role, as the providers of the other service roles do; see [Provide a role](https://doc.saymyframe.com/extending/role-provider.md). The doc comments of the interface in the app hold its contract. In short:

- The function that creates the service creates it without a user, asks the user nothing and never throws. It may be called again, and the new service has what the store and the provider kept.
- `identify(userId)` comes before any other call. `null` stands for the user of this device. Identifying the user who is identified already does nothing.
- `identify(null)` after another user starts a new user of this device, with nothing bought. When the user of this device is identified as a user that no `identify` named before, on any device, that user takes over the purchases of the user of this device. Any other identification keeps the purchases of each user apart.
- `access()` completes from what the device keeps, and `accessChanges` tells of each change of the access that the provider learns of.
- `plans()` gives at least one plan. A backend with nothing to offer fails with `notConfigured` and a hint.
- `purchase(plan)` completes with the premium access once the store took the payment, and with `null` when the user cancelled. It fails with `pending` while the payment waits for an approval, and with `alreadyOwned` for a plan that the store account owns.
- Every call fails only with a `PurchasesFailure`. Without what it needs, such as a key, the provider fails every call with `notConfigured` and a hint that names what is missing, and never stops the app.

The structural rule `purchases.factory_calls` checks that only the files of the implementation import the file of the interface, `purchases.start_calls` that only `bootstrap()` starts the purchases, and `purchases.implementation_factories` that the function of the implementation is in its file and takes no arguments. Only a running app shows whether a provider keeps to the rest.

## The README and the guide for coding agents

The purchases role writes the section "Purchases" of the README of the app: what `appPurchases` tells and does, what a screen that sells shows, the premium routes, whose the purchases are and where the user restores them, and what the app does while purchases are not set up. Each module adds a section of its own, such as "RevenueCat" and "Paywall".

The [guide for coding agents](https://doc.saymyframe.com/guides/coding-agents.md) of the app has the section "Purchases", with a note of the role and one of the provider. In an app with sign-in, the role adds whose the purchases are, and in an app with a settings screen, where the restore is. [`paywall`](https://doc.saymyframe.com/modules/paywall.md) writes the section "Paywall".

## In tests

A test of an app with the role runs `bootstrap()`, which starts the service of the provider. The package of [`revenuecat`](https://doc.saymyframe.com/modules/revenuecat.md#in-tests) has no platform side for tests, so a test answers its channel itself.

| A test that | Does this |
| --- | --- |
| scripts what a provider answers | It gives `appPurchases.start()` a service of its own, a class of the test that implements `PurchasesService`, with a user of its own, such as the value of a `ValueNotifier`. |
| checks what the next launch finds | It runs `initPurchases()` again, which creates the service anew with what the store and the provider kept. |
| starts the app with premium access | It buys a plan before `bootstrap()`, with `appPurchases.plans().then((plans) => appPurchases.purchase(plans.first))` made outside the body of a test, as in a `setUpAll`. Such a call runs within the first start, so the app starts with the access. |
| brings the user back to the app | It may go through any state of the lifecycle: the role looks only at `AppLifecycleState.resumed`. |

The mocks of a provider start as on a first launch: nothing is bought, and the offer has at least one plan. A purchase by a user without premium access succeeds at once.

Whichever module provides the role, a fresh user who buys a plan has premium access, the next start of the app gives the same user the same access, and the purchases of each user stay apart. SMF's CI checks this in running apps with the role, with sign-in in each of its modes and without it, with a fixture provider, and with a broken one that keeps the purchases of every user together, which the tests must fail on. It also checks the row Restore purchases of the settings screen. See [Contributing](https://doc.saymyframe.com/contributing.md#the-roles-at-run-time).
