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 | 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 | 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:
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:
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:
import 'core/purchases/app_purchases.dart';
ValueListenableBuilder<bool>(
valueListenable: appPurchases.hasPremium,
builder: (context, premium, _) =>
premium ? const ExportButton() : const UpgradeHint(),
);
In an app with paywall, 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 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.
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, 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, 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 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 and of the products 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. 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.nullstands 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 noidentifynamed 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, andaccessChangestells of each change of the access that the provider learns of.plans()gives at least one plan. A backend with nothing to offer fails withnotConfiguredand a hint.purchase(plan)completes with the premium access once the store took the payment, and withnullwhen the user cancelled. It fails withpendingwhile the payment waits for an approval, and withalreadyOwnedfor 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 withnotConfiguredand 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 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 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 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.