Adding in-app purchases to your Flutter app

1. Introduction

Last Updated: 2021-05-04

Adding in-app purchases to a Flutter app requires correctly setting up the App and Play stores, verifying the purchase, and granting the necessary permissions, such as subscription perks.

In this codelab you'll add three types of in-app purchases to an app (provided for you), and verify these purchases using a backend Firebase service. The provided app, Dash Clicker, contains a game that uses the Dash mascot as currency. You will add the following purchase options:

  1. A repeatable purchase option for 2000 Dashes at once.
  2. A one-time upgrade purchase to make the old style Dash into a modern style Dash.
  3. A subscription that doubles the automatically generated clicks.

The first purchase option gives the user a direct benefit of 2000 Dashes. These are directly available to the user and this can be bought many times. This is called a consumable as it is directly consumed and can be consumed multiple times.

The second option upgrades the Dash to a more beautiful Dash. This only has to be purchased once and is available forever. Such a purchase is called a non-consumable because it cannot be consumed by the app but is valid forever.

The third and last purchase option is a subscription. While the subscription is active the user will get Dashes more quickly, but when he stops paying for the subscription the benefits also go away.

The backend service (also provided for you) verifies that the purchases are made in Firebase using Cloud Functions and Firestore. These Firebase products are used to make the process easier, but in your production app you can use any type of backend service.

f6c2860aa301338e.png 23f152b310b2be01.png 5ab4c7541965ac6.png

What you'll build

  • You will extend an app to support consumable purchases and subscriptions.
  • You will also extend a Firebase backend app to verify and store the purchased items.

What you'll learn

  • How to configure the App Store and Play Store with purchasable products.
  • How to use Firebase functions and Firestore to manage purchases.
  • How to manage purchases in your app.

What you'll need

  • Android Studio 4.1 or later
  • Xcode 12 or later (for iOS development)
  • Flutter SDK

2. Set up the development environment

To start this codelab, download the code and change the bundle identifier for iOS and the package name for Android.

Download the code

To clone the GitHub repository from the command line, use the following command:

git clone https://github.com/flutter/codelabs.git flutter-codelabs

Or, if you have GitHub's cli tool installed, use the following command:

gh repo clone flutter/codelabs flutter-codelabs

The sample code is cloned into a flutter-codelabs directory that contains the code for a collection of codelabs. The code for this codelab is in flutter-codelabs/in_app_purchases.

The directory structure under flutter-codelabs/in_app_purchases contains a series of snapshots of where you should be at the end of each named step. The starter code is in step 0, so locating the matching files is as easy as:

cd flutter-codelabs/in_app_purchases/step_00

If you want to skip forward, or see what something should look like after a step, look in the directory named after the step you are interested in. The code of the last step is under the folder complete.

Set up the starter project

Open the starter project from step_00 in your favorite IDE. We used Android Studio for the screenshots, but Visual Studio Code is also a great option. With either editor, ensure that the latest Dart and Flutter plugins are installed.

The apps you are going to make need to communicate with the App Store and Play Store to know which products are available and for what price. Every app is identified by a unique ID. For the iOS App Store this is called the bundle identifier and for the Android Play Store this is the application ID. These identifiers are usually made using a reverse domain name notation. For example when making an in app purchase app for flutter.dev we would use dev.flutter.inappurchase. Think of an identifier for your app, you are now going to set that in the project settings.

First, set up the bundle identifier for iOS.

With the project open in Android Studio, right-click the iOS folder, click Flutter, and open the module in the Xcode app.

730f65d15da53ae8.png

In Xcode's folder structure, the Runner project is at the top, and the Flutter, Runner, and Products targets are beneath the Runner project. Double-click Runner to edit your project settings, and click Signing & Capabilities. Enter the bundle identifier you've just chosen under the Team field to set your team.

186dfae82172861.jpeg

For Android, go back to your IDE, open the build.gradle file under android/app, and change your applicationId on line 37 to the application ID, the same as the iOS bundle identifier. Note that the IDs for the iOS and Android stores don't have to be identical, but you will make them so for this codelab.

cbf7ce47aee66930.png

3. Set up the App Store

To set up in-app purchases and test it on iOS, you need to create a new app in the App Store and create purchasable products there. You don't have to publish anything or send the app to Apple for review. You need a developer account to do this. If you don't have one, enroll in the Apple developer program.

To use in-app purchases, you also need to have an active agreement for paid apps in App Store Connect. Go to https://appstoreconnect.apple.com/, and click Agreements, Tax, and Banking.

de22def107212c78.png

You will see agreements here for free and paid apps. The status of free apps is should be active, and the status for paid apps is new. Make sure that you view the terms, accept them, and enter all required information.

f6ff8a29a7fea3f.png

When everything is set correctly, the status for paid apps will be active. This is very important because you won't be able to try in-app purchases without an active agreement.

336543473f495199.jpeg

Creating a new app

Create a new app in App Store Connect with your unique bundle identifier.

afc4317f3684868d.png

a991c8b6071f819c.png

For more guidance about how to create a new app and manage agreements, see the App Store Connect help.

To test the in-app purchases, you need a sandbox test user. This test user shouldn't be connected to iTunes—it's only used for testing in-app purchases. You can't use an email address that is already used for an Apple account. In Users and Access, go to Testers under Sandbox to create a new sandbox account or to manage the existing sandbox Apple IDs.

16306eaf28dfde52.jpeg

Configuring your in-app purchases

Now you'll configure the three purchasable items:

  • dash_consumable_2k: A consumable purchase that can be purchased many times over, which grants the user 2000 Dashes (the in-app currency) per purchase.
  • dash_upgrade_3d: A non-consumable "upgrade" purchase that can only be purchased once, and gives the user a cosmetically different Dash to click.
  • dash_subscription_doubler: A subscription that grants the user twice as many Dashes per click for the duration of the subscription.

850285cbd6ba0d67.png

Go to In-App Purchases > Manage.

Create your in-app purchases with the specified ids:

  1. Set up dash_consumable_2k as a Consumable.

Use dash_consumable_2k as the Product ID. The reference name is only used in app store connect, just set it to dash consumable 2k and add your localizations for the purchase. Call the purchase Spring is in the air with 2000 dashes fly out as the description.

ad34a552b8aedbf2.png

  1. Set up dash_upgrade_3d as a Non-consumable.

Use dash_upgrade_3d as the Product ID. Set the reference name to dash upgrade 3d and add your localizations for the purchase. Call the purchase 3D Dash with Brings your dash back to the future as the description.

4ad51fb706476833.png

  1. Set up dash_subscription_doubler as an Auto-renewing subscription.

The flow for subscriptions is a bit different. First you'll have to set the Reference Name and Product ID:

8ffb93db346b5d9c.png

Next, you have to create a subscription group. When multiple subscriptions are part of the same group, a user can only subscribe to one of these at the same time, but can easily upgrade or downgrade between these subscriptions. Just call this group subscriptions.

79f21b5eb1adef89.png

Next, enter the subscription duration and the localizations. Name this subscription Jet Engine with the description Doubles your clicks. Click Save.

14fb1375b466ac29.png

After you've clicked the Save button, add a subscription price. Pick any price you desire.

e054dc57ee7d310a.png

You should now see the three purchases in the list of purchases:

fd5bf4a092056980.png

4. Set up the Play Store

As with the App Store, you'll also need a developer account for the Play Store. If you don't have one yet, register an account.

Create a new app

Create a new app in the Google Play Console:

  1. Open the Play Console.
  2. Select All apps > Create app.
  3. Select a default language and add a title for your app. Type the name of your app as you want it to appear on Google Play. You can change the name later.
  4. Specify that your application is a game. You can change this later.
  5. Specify whether your application is free or paid.
  6. Add an email address that Play Store users can use to contact you about this application.
  7. Complete the Content guidelines and US export laws declarations.
  8. Select Create app.

After your app is created, go to the dashboard, and complete all the tasks in the Set up your app section. Here, you provide some information about your app, such as content ratings and screenshots. df1ab6e06a739df.png

Sign the application

To be able to test in-app purchases, you need at least one build uploaded to Google Play.

For this, you need your release build to be signed with something other than the debug keys.

Create a keystore

If you have an existing keystore, skip to the next step. If not, create one by running the following at the command line.

On Mac/Linux, use the following command:

keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

On Windows, use the following command:

keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key

This command stores the key.jks file in your home directory. If you want to store the file elsewhere, then change the argument you pass to the -keystore parameter. Keep the

keystore

file private; don't check it into public source control!

Reference the keystore from the app

Create a file named <your app dir>/android/key.properties that contains a reference to your keystore:

storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=key
storeFile=<location of the key store file, such as /Users/<user name>/key.jks>

Configure signing in Gradle

Configure signing for your app by editing the <your app dir>/android/app/build.gradle file.

Add code before the android block:

   android {
      // omitted
   }

With the keystore information from your properties file:

   def keystoreProperties = new Properties()
   def keystorePropertiesFile = rootProject.file('key.properties')
   if (keystorePropertiesFile.exists()) {
       keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
   }

   android {
         // omitted
   }

Load the key.properties file into the keystoreProperties object.

Add the following code before the buildTypes block:

   buildTypes {
       release {
           // TODO: Add your own signing config for the release build.
           // Signing with the debug keys for now,
           // so `flutter run --release` works.
           signingConfig signingConfigs.debug
       }
   }

Configure the signingConfigs block in your module's build.gradle file with the signing configuration information:

   signingConfigs {
       release {
           keyAlias keystoreProperties['keyAlias']
           keyPassword keystoreProperties['keyPassword']
           storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
           storePassword keystoreProperties['storePassword']
       }
   }
   buildTypes {
       release {
           signingConfig signingConfigs.release
       }
   }

Release builds of your app will now be signed automatically.

For more information about signing your app, see Sign your app on developer.android.com.

Upload your first build

After your app is configured for signing, you should be able to build your application by running:

flutter build appbundle

This command generates a release build by default.

From the dashboard in the Google Play Console, go to Release > Testing > Internal testing, and create a new, internal testing release.

For this codelab, you'll stick to Google signing the app, so go ahead and press Continue under Play App Signing to opt in.

7dbfd0524a6ac0d8.png

Next, upload the app-release.aab app bundle that was generated by the build command.

Click Save and then click Review release.

Finally, click Start rollout to Internal testing to activate the internal testing release.

Set up test users

To be able to test in-app purchases, Google accounts of your testers must be added in the Google Play console in two locations:

  1. To the specific test track (Internal testing)
  2. As a license tester

First, start with adding the tester to the internal testing track. Go back to Release > Testing > Internal testing and click the Testers tab.

9bec9386c8253a7.png

Create a new email list by clicking Create email list. Give the list a name, and add the email addresses of the Google accounts that need access to testing in-app purchases.

Next, select the checkbox for the list, and click Save changes.

Then, add the license testers:

  1. Go back to the All apps view of the Google Play Console.
  2. Go to Settings > License testing.
  3. Add the same email addresses of the testers who need to be able to test in-app purchases.
  4. Set License response to RESPOND_NORMALLY.
  5. Click Save changes.

Configuring your in-app purchases

Now you'll configure the items that are purchasable within the app.

Just like in the App Store, you have to define three different purchases:

  • dash_consumable_2k: A consumable purchase that can be purchased many times over, which grants the user 2000 Dashes (the in-app currency) per purchase.
  • dash_upgrade_3d: A non-consumable "upgrade" purchase that can only be purchased once, which gives the user a cosmetically different Dash to click.
  • dash_subscription_doubler: A subscription that grants the user twice as many Dashes per click for the duration of the subscription.

First, add the consumable and non-consumable.

  1. Go to the Google Play Console, and select your application.
  2. Go to Monetize > Products > In-app products.
  3. Click Create producta656c58e842c45a7.png
  4. Enter all the required information for your product. Make sure the product ID matches the ID that you intend to use exactly.
  5. Click Save.
  6. Repeat the process for the non-consumable "upgrade" purchase.

Next, add the subscription:

  1. Go to the Google Play Console, and select your application.
  2. Go to Monetize > Products > Subscriptions.
  3. Click Create subscriptione21aa175cc5b878.png
  4. Enter all the required information for your subscription. Make sure the product ID matches the ID you intend to use exactly.
  5. Click Save

Your purchases should now be set up in the Play Console.

5. Set up Firebase

In this codelab, you'll use a backend service to verify and track users' purchases.

Using a backend service has several benefits:

  • You can securely verify transactions.
  • You can react to billing events from the app stores.
  • You can keep track of the purchases in a database.
  • Users won't be able to fool your app into providing premium features by rewinding their system clock.

While there are many ways to set up a backend service, you'll do this using cloud functions and Firestore, using Google's own Firebase.

Writing the backend is considered out of scope for this codelab, so the starter code already includes a Firebase project that handles basic purchases to get you started.

Firebase plugins are also included with the starter app.

What's left for you to do is to create your own Firebase project, configure both the app and backend for Firebase, and finally deploy the backend.

Create a Firebase project

Go to the Firebase console, and create a new Firebase project. For this example, call the project Dash Clicker.

In the backend app, you tie purchases to a specific user, therefore, you need authentication. For this, leverage Firebase's authentication module with Google sign-in.

  1. From the Firebase dashboard, go to Authentication and enable it, if needed.
  2. Go to the Sign-in method tab, and enable the Google sign-in provider.

5230a3c58e90babd.png

Because you'll also use Firebases's Firestore database and cloud functions, enable these too.

e4510c5822ec292a.png

a6947eae8acdbf6.png

Set up Firebase for Android

From the Firebase dashboard, go to Project Overview, choose + Add App from the header, and add an Android app.

43493680d62edb2a.png

In the modal that pops up, you need to provide some basic information.

Make sure that the Android package name you enter is the package name used earlier in this codelab, dev.flutter.inapppurchase . You can come up with your own App nickname.

991a819daf84911c.png

To allow Google sign-in in debug mode, you must provide the SHA-1 hash of your debug certificate.

Get your debug signing certificate hash

In the root of your Flutter app project, change directory to the android/ folder and generate a signing report:

cd android
./gradlew :app:signingReport

You'll be presented with a large list of signing keys. Because you're looking for the hash for the debug certificate, look for the certificate with the Variant and Config properties set to debug. It's likely for the keystore to be in your home folder under .android/debug.keystore.

> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038

Copy the SHA-1 hash, and fill in the last field in the app submission modal dialog.

Configure the Flutter app for Firebase

After you register the app, download the google-services.json file, and add it to the android/app directory.

4ae2a38c292e27f4.png

Next open the build.gradle file in android/app and uncomment the last line of code:

build.gradle

apply plugin: ‘com.google.gms.google-services'

Set up Firebase for iOS

The setup for iOS is similar: add an app to your Firebase project from the Project Overview page, but this time add an iOS app.

Again, use the same bundle ID that you used earlier in the codelab, dev.flutter.inapppurchase .

After registering the app, download the GoogleServices-Info.plist file, and replace the existing placeholder file in the ios folder of your Flutter project.

2d4354225e75055b.png

To allow for Google sign-in on iOS, add the CFBundleURLTypes configuration option to your build plist files. (Check the google_sign_in package docs for more information.) In this case, the files are ios/Runner/Info-Debug.plist and ios/Runner/Info-Release.plist.

The key-value pair was already added, but their values must be replaced:

  1. Get the value for REVERSED_CLIENT_ID from the GoogleService-Info.plist file, without the <string>..</string> element surrounding it.
  2. Replace the value in both your ios/Runner/Info-Debug.plist and ios/Runner/Info-Release.plist files under the CFBundleURLTypes key.
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <!-- TODO Replace this value: -->
            <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
            <string>com.googleusercontent.apps.REDACTED</string>
        </array>
    </dict>
</array>

You are now done with the Firebase setup.

6. Listen to purchase updates

In this part of the codelab you'll prepare the app for purchasing the products. This process includes initializing the plugin and listening to purchase updates and errors after the app starts.

Add dependency in pubspec

First, add in_app_purchase to the pubspec by adding in_app_purchase: ^1.0.0 to the dependencies in your pubspec:

pubspec.yaml

dependencies:
  firebase_core: "^1.0.3"
  firebase_auth: "^1.0.3"
  cloud_functions: "^1.0.3"
  cloud_firestore: "^1.0.4"
  google_sign_in: "^5.0.1"
  in_app_purchase: "^1.0.0"
  
  flutter:
    sdk: flutter

Click pub get to download the package or run flutter pub get in the command line.

Listen to purchase updates

Next, call enablePendingPurchases before runApp so the Android platform knows to send those updates to the app. Also, add the import; main.dart looks like the following:

lib/main.dart

import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';


void main() {
  InAppPurchaseAndroidPlatformAddition.enablePendingPurchases();
  runApp(MyApp());
}

In main.dart, find the widget MyHomePage that has a Scaffold with a BottomNavigationBar containing two pages. This page also creates three Providers for DashCounter, DashUpgrades. DashPurchases. DashCounter tracks the current count of Dashes and auto increments them. DashUpgrades manages the upgrades that you can buy with Dashes. This codelab focuses on DashPurchases.

By default, the object of a provider is defined when that object is first requested. This object listens to purchase updates directly when the app starts, so disable lazy loading on this object with lazy: false:

lib/main.dart

ChangeNotifierProvider<DashPurchases>(
  create: (context) => DashPurchases(
    context.read<DashCounter>(),
  ),
  lazy: false,
),

You also need an instance of the InAppPurchaseConnection. However, to keep the app testable you need some way to mock the connection. To do this, create an instance method that can be overridden in the test, and add it to main.dart.

lib/main.dart

// Gives the option to override in tests.
class IAPConnection {
  static InAppPurchase? _instance;
  static set instance(InAppPurchase value) {
    _instance = value;
  }

  static InAppPurchase get instance {
    _instance ??= InAppPurchase.instance;
    return _instance!;
  }
}

You must slightly update the test if you want the test to keep working. Check widget_test.dart on GitHub for the full code for TestIAPConnection.

test/widget_test.dart

void main() {
  testWidgets('App starts', (WidgetTester tester) async {
    IAPConnection.instance = TestIAPConnection();
    await tester.pumpWidget(MyApp());
    expect(find.text('Tim Sneath'), findsOneWidget);
  });
}

In lib/logic/dash_purchases.dart, go to the code for DashPurchases ChangeNotifier. Currently, there is only a DashCounter that you can add to your purchased Dashes.

Add a stream subscription property, _subscription (of type StreamSubscription<List<PurchaseDetails>> _subscription;), the IAPConnection.instance, and the imports. The resulting code should look at follows:

lib/logic/dash_purchases.dart

import 'package:in_app_purchase/in_app_purchase.dart';

class DashPurchases extends ChangeNotifier {
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter);
}

The late keyword is added to _subscription because the _subscription is initialized in the constructor. This project is set up to be non-nullable by default (NNBD), which means that properties that aren't declared nullable must have a non-null value. The late qualifier lets you delay defining this value.

In the constructor, get the purchaseUpdatedStream and start listening to the stream. In the dispose() method, cancel the stream subscription.

lib/logic/dash_purchases.dart

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter) {
    final purchaseUpdated =
        iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }

  Future<void> buy(PurchasableProduct product) async {
    // omitted
  }

  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

  void _updateStreamOnDone() {
    _subscription.cancel();
  }

  void _updateStreamOnError(dynamic error) {
    //Handle error here
  }
}

Now, the app receives the purchase updates so, in the next section, you'll make a purchase!

7. Make purchases

In this part of the codelab, you'll replace the currently existing mock products with real purchasable products. These products are loaded from the stores, shown in a list, and are purchased when tapping the product.

Adapt PurchasableProduct

PurchasableProduct displays a mock product. Update it to show actual content by replacing the PurchasableProduct class in purchasable_product.dart with the following code:

lib/model/purchasable_product.dart

import 'package:in_app_purchase/in_app_purchase.dart';

enum ProductStatus {
  purchasable,
  purchased,
  pending,
}

class PurchasableProduct {
  String get id => productDetails.id;
  String get title => productDetails.title;
  String get description => productDetails.description;
  String get price => productDetails.price;
  ProductStatus status;
  ProductDetails productDetails;

  PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}

In dash_purchases.dart, remove the dummy purchases and replace them with an empty list, List<PurchasableProduct> products = [];

Load available purchases

To give a user the ability to make a purchase, load the purchases from the store. First, check whether the store is available. When the store isn't available, setting storeState to notAvailable displays an error message to the user.

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
  }

When the store is available, load the available purchases. Given the previous Firebase setup, expect to see storeKeyConsumable, storeKeySubscription, and storeKeyUpgrade. When an expected purchase isn't available, print this information to the console; you might also want to send this info to the backend service.

The await iapConnection.queryProductDetails(ids) method returns both the IDs that aren't found and the purchasable products that are found. Use the productDetails from the response to update the UI, and set the StoreState to available.

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
    const ids = <String>{
      storeKeyConsumable,
      storeKeySubscription,
      storeKeyUpgrade,
    };
    final response =
        await iapConnection.queryProductDetails(ids);
    response.notFoundIDs.forEach((element) {
      print('Purchase $element not found');
    });
    products =
        response.productDetails.map((e) => PurchasableProduct(e)).toList();
    storeState = StoreState.available;
    notifyListeners();
  }

Call the loadPurchases() function in the constructor:

lib/logic/dash_purchases.dart

  DashPurchases(this.counter) {
    final purchaseUpdated =
        iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();
  }

Finally, change the value of storeState from StoreState.available to StoreState.loading:

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

Show the purchasable products

Consider the purchase_page.dart file. The PurchasePage widget shows _PurchasesLoading, _PurchaseList, or _PurchasesNotAvailable, depending on the StoreState. The widget also shows the user's past purchases which is used in the next step.

The _PurchaseList widget shows the list of purchasable products and sends a buy request to the DashPurchases object.

lib/pages/purchase_page.dart

class _PurchaseList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var purchases = context.watch<DashPurchases>();
    var products = purchases.products;
    return Column(
      children: products
          .map((product) => _PurchaseWidget(
              product: product,
              onPressed: () {
                purchases.buy(product);
              }))
          .toList(),
    );
  }
}

You should be able to see the available products on the Android and iOS stores if they are configured correctly. Note that it can take some time before the purchases are available when entered into the respective consoles.

f310ec1df56f5eda.png

Go back to dash_purchases.dart, and implement the function to buy a product. You only need to separate the consumables from the non-consumables. The upgrade and the subscription products are non-consumables.

lib/logic/dash_purchases.dart

  Future<void> buy(PurchasableProduct product) async {
    final purchaseParam = PurchaseParam(productDetails: product.productDetails);
    switch (product.id) {
      case storeKeyConsumable:
        await iapConnection.buyConsumable(purchaseParam: purchaseParam);
        break;
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
        break;
      default:
        throw ArgumentError.value(
            product.productDetails, '${product.id} is not a known product');
    }
  }

The _onPurchaseUpdate method receives the purchase updates, updates the status of the product that is shown in the purchase page, and applies the purchase to the counter logic. It's important to call completePurchase after handling the purchase so the store knows the purchase is handled correctly.

lib/logic/dash_purchases.dart

  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    purchaseDetailsList.forEach(_handlePurchase);
    notifyListeners();
  }

  void _handlePurchase(PurchaseDetails purchaseDetails) {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      switch (purchaseDetails.productID) {
        case storeKeySubscription:
          counter.applyPaidMultiplier();
          break;
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
          break;
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
          break;
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      iapConnection.completePurchase(purchaseDetails);
    }
  }

8. Set up the backend

Before moving on to tracking and verifying purchases, set up a backend to support doing so. For this, you use Firebase.

In this section, work from the firebase-backend/ folder as the root.

Make sure that you have the following tools installed:

Base project overview

Because some parts of this project are considered out of scope for this codelab, they are included in the starter code. It's a good idea to go over what is already in the starter code before you get started, to get an idea of how you're going to structure things.

One part that is already included in the starter code is the IapRepository in functions/src/iap.repository.ts. Because learning how to interact with Firestore, or databases in general, isn't considered to be relevant to this codelab, the starter code contains functions for you to create or update purchases in the Firestore, as well as all the interfaces for those purchases.

First, create a link to your Firebase project by running the following inside your firebase-backend project folder:

firebase use --add

You will be prompted to select the Firebase project you want to link to. Select the project you just created.

When asked to supply an alias for the project, call it default.

The contents of .firebaserc should look like the following:

{
  "projects": {
    "default": "<YOUR_FIREBASE_PROJECT_ID>"
  }
}

Configure cloud functions

If you don't want to run your cloud functions in the europe-west1 region, open functions/src/constants.ts, and adjust the value of the CLOUD_REGION variable to your desired region. To see all available options, see the Cloud Functions locations page.

Set up Google Play access

To access the Play Store for verifying purchases, you must generate a service account with these permissions, and download the JSON credentials for it.

Only the owner of the developer account can set this up. An invited user with admin credentials doesn't have the necessary permissions to generate these credentials.

  1. Go to the Google Play Console, and start from the All apps page.
  2. Go to Settings > Developer account > API access. 22c7cb3c428c57c2.png In case the Google Play Console requests that you create or link to an existing project, do so first and then come back to this page.
  3. Find the section where you can define service accounts, and click Create new service account.c1d9a2d8c09e42ea.png
  4. Click the Google Cloud Platform link in the dialog that pops up. ca9461d2d55376a3.png
  5. Select your project. If you don't see it, make sure that you are signed in to the correct Google account under the Account drop-down list in the top right. ca481b67248705a2.png
  6. After selecting your project, click + Create Service Account in the top menu bar. f3d3f6f1be43e11b.png
  7. Provide a name for the service account, optionally provide a description so that you'll remember what it's for, and go to the next step. 687c088295613756.png
  8. Assign the service account the Editor role. 82a53bd653c48d25.png
  9. Finish the wizard, go back to the API Access page within the developer console, and click Refresh service accounts. You should see your newly created account in the list. 1212db89b051d0be.png
  10. Click Grant access for your new service account.
  11. Scroll down the next page, to the Financial data block. Select both View financial data, orders, and cancellation survey responses and Manage orders and subscriptions. 7f528ac22ab77430.png
  12. Click Send invite. 2101eebb448f9878.png
  13. Now that the account is set up, you just need to generate some credentials. Back in the cloud console, find your service account in the list of service accounts, click the three vertical dots, and choose Manage keys. 98fd3355dd5b1268.png
  14. Create a new JSON key and download it. 6aaf82b16fcc1ac9.png 3a053b5b23033b4d.png
  15. Rename the downloaded file to service-account.json, and place it in functions/src/assets/service-account.json.
  16. Open functions/src/constants.ts, and replace the value of ANDROID_PACKAGE_ID with the package ID that you chose for your Android app.

Set up Apple App Store access

To access the App Store for verifying purchases, you have to set up a shared secret:

  1. Open App Store Connect.
  2. Go to My Apps, and select your app.
  3. In the sidebar navigation, go to In-App Purchases > Manage.
  4. At the top left of the list, click App-Specific Shared Secret.
  5. Generate a new secret, and copy it.
  6. Open functions/src/constants.ts, and replace the value of APP_STORE_SHARED_SECRET with the shared secret you just generated.

540a08524e3c6558.png

7355e73de59b376.png

Deploy the project

Even though there aren't any cloud functions in the starter project, deploying now allows you to check whether you can deploy successfully, as well as deploy the rule and index configurations for Firestore.

You can deploy the first version of the backend by running the following:

firebase deploy

9. Verify purchases

The general flow for verifying purchases is similar for iOS and Android.

For both stores, your application receives a token when a purchase is made.

This token is sent by the app to your backend service, which then, in turn, verifies the purchase with the respective store's servers using the provided token.

The backend service can then choose to store the purchase, and reply to the application whether the purchase was valid or not.

By having the backend service do the validation with the stores rather than the application running on your user's device, you can prevent the user gaining access to premium features by, for example, rewinding their system clock.

Set up the Flutter side

Set up authentication

As you are going to send the purchases to your backend service, you want to make sure the user is authenticated while making a purchase. Most of the authentication logic is already added for you in the starter project, you just have to make sure the PurchasePage shows the login button when the user is not logged in yet. Add the following code to the beginning of the build method of PurchasePage:

lib/pages/purchase_page.dart

import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';

class PurchasePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var firebaseNotifier = context.watch<FirebaseNotifier>();
    if (firebaseNotifier.state == FirebaseState.loading) {
      return _PurchasesLoading();
    } else if (firebaseNotifier.state == FirebaseState.notAvailable) {
      return _PurchasesNotAvailable();
    }

    if (!firebaseNotifier.loggedIn) {
      return LoginPage();
    }
    // omitted

Call cloud function from the app

In the app, use the cloud_functions library to make calling the function you create easy. Create the _verifyPurchase(PurchaseDetails purchaseDetails) function that calls the corresponding backend function using functions.httpsCallable('verifyPurchase'). Send the selected store (google_play for the Play Store or app_store for the App Store), the serverVerificationData, and the productID. The server returns a boolean indicating whether the purchase is verified.

In dash_purchases, extend the constructor to also include the FirebaseNotifier from which you get access to Cloud Functions.

lib/logic/dash_purchases.dart

  FirebaseNotifier firebaseNotifier;

  DashPurchases(this.counter, this.firebaseNotifier) {
    // omitted
  }

Add the firebaseNotifier with the creation of DashPurchases in main.dart:

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
          ),
          lazy: false,
        ),

Add the function _verifyPurchase to the DashPurchases class. This async function returns a boolean indicating whether the purchase is validated.

lib/logic/dash_purchases.dart

  Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
    var functions = await firebaseNotifier.functions;
    final callable = functions.httpsCallable('verifyPurchase');
    final results = await callable({
      'source':
          purchaseDetails.verificationData.source,
      'verificationData':
          purchaseDetails.verificationData.serverVerificationData,
      'productId': purchaseDetails.productID,
    });
    return results.data as bool;
  }

Call the _verifyPurchase function in _handlePurchase just before you apply the purchase. You only apply the purchase when it's verified. In a production app, you can specify this further to, for example, apply a trial subscription when the store is temporarily unavailable. However, for this example, keep it simple, and only apply the purchase when the purchase is verified successfully.

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
      List<PurchaseDetails> purchaseDetailsList) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      // Send to server
      var validPurchase = await _verifyPurchase(purchaseDetails);

      if (validPurchase) {
        // Apply changes locally
        switch (purchaseDetails.productID) {
          case storeKeySubscription:
            counter.applyPaidMultiplier();
            break;
          case storeKeyConsumable:
            counter.addBoughtDashes(1000);
            break;
        }
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

When using cloud functions to validate the purchase, ensure that the cloud functions are ready to use before making the store available. Do that by adding the following lines to the loadPurchases function just after the iapConnection.isAvailable() check.

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
    try{
      await firebaseNotifier.functions;
    }catch(e){
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
  // omitted
  }

In the app everything is now ready to validate the purchases.

Set up the backend service

Next, set up the cloud function for verifying purchases on the backend .

Build purchase handlers

Because the verification flow for both stores is close to identical, set up an abstract PurchaseHandler class with separate implementations for each store.

b30de22e17fbdf9c.png

Start by adding a purchase-handler.ts file to the functions/src/ folder, where you define an abstract PurchaseHandler class with two abstract methods for verifying two different kinds of purchases: subscriptions and non-subscriptions.

functions/src/purchase-handler.ts

export abstract class PurchaseHandler {
  // For handling consumables and non-consumables
  abstract handleNonSubscription(
      userId: string,
      productData: ProductData,
      token: string,
  ): Promise<boolean>;

  // For handling subscriptions
  abstract handleSubscription(
      userId: string,
      productData: ProductData,
      token: string,
  ): Promise<boolean>;
}

As you can see, each method requires three parameters:

  • userId: The ID of the logged-in user, so you can tie purchases to the user.
  • productData: Data about the product. You are going to define this in a minute.
  • token: The token provided to the user by the store.

Additionally, to make these purchase handlers easier to use, add a verifyPurchase() method that can be used for both subscriptions and non-subscriptions:

functions/src/purchase-handler.ts

export abstract class PurchaseHandler {
  // omitted
  async verifyPurchase(
      userId: string,
      productData: ProductData,
      token: string,
  ): Promise<boolean> {
    switch (productData.type) {
      case "SUBSCRIPTION":
        return this.handleSubscription(userId, productData, token);
      case "NON_SUBSCRIPTION":
        return this.handleNonSubscription(userId, productData, token);
      default:
        return false;
    }
  }
}

Now, you can just call verifyPurchase for both cases, but still have separate implementations!

At this point, you're probably wondering where the ProductData type comes from. You'll define it now.

Create a new file at functions/src/products.ts. First, define the interface for our ProductData:

functions/src/products.ts

export interface ProductData {
  productId: string;
  type: "SUBSCRIPTION" | "NON_SUBSCRIPTION";
}

You can now import ProductData in your purchase-handler.ts file by adding the following line at the top:

functions/src/purchase-handler.ts

import {ProductData} from "./products";

Next, define some placeholder implementations for the Google Play Store and the Apple App Store. Start with Google Play:

Create functions/src/google-play.purchase-handler.ts, and add a class that extends the PurchaseHandler you just wrote:

functions/src/google-play.purchase-handler.ts

import {PurchaseHandler} from "./purchase-handler";
import {IapRepository} from "./iap.repository";
import {ProductData} from "./products";

export class GooglePlayPurchaseHandler extends PurchaseHandler {
    constructor(private iapRepository: IapRepository) {
        super();
    }

    async handleNonSubscription(
        userId: string | null,
        productData: ProductData,
        token: string,
    ): Promise<boolean> {
        return true;
    }

    async handleSubscription(
        userId: string | null,
        productData: ProductData,
        token: string,
    ): Promise<boolean> {
        return true;
    }
}

For now, it returns true for the handler methods; you'll get to them later.

As you might have noticed, the constructor takes an instance of the IapRepository as a class field. The purchase handler uses this instance to store information about purchases in Firestore later on.

Next, do the same for the app store handler. Create functions/src/app-store.purchase-handler.ts, and add a class that extends the PurchaseHandler again:

functions/src/app-store.purchase-handler.ts

import {PurchaseHandler} from "./purchase-handler";
import {IapRepository} from "./iap.repository";
import {ProductData} from "./products";

export class AppStorePurchaseHandler extends PurchaseHandler {
    constructor(private iapRepository: IapRepository) {
        super();
    }

    async handleNonSubscription(
        userId: string | null,
        productData: ProductData,
        token: string,
    ): Promise<boolean> {
        return true;
    }

    async handleSubscription(
        userId: string | null,
        productData: ProductData,
        token: string,
    ): Promise<boolean> {
        return true;
    }
}

Great! Now you have two purchase handlers. Next, attach them to a cloud function.

Use purchase handlers

Within functions/src/index.ts, define a cloud function. First, declare some dependencies:

functions/src/index.ts

import * as Functions from "firebase-functions";
//omitted

// Get a cloud functions instance that is specific to your region.
const functions = Functions.region(CLOUD_REGION);
// Initialize the IAP repository that the purchase handlers depend on
const iapRepository = new IapRepository(admin.firestore());
// Initialize an instance of each purchase handler, and store them in a map for easy access.
const purchaseHandlers: { [source in IAPSource]: PurchaseHandler } = {
  "google_play": new GooglePlayPurchaseHandler(iapRepository),
  "app_store": new AppStorePurchaseHandler(iapRepository),
};

Next, define the cloud function:

functions/src/index.ts

// Verify Purchase Function
interface VerifyPurchaseParams {
  source: IAPSource;
  verificationData: string;
  productId: string;
}

// Handling of purchase verifications
export const verifyPurchase = functions.https.onCall(
    async (
        data: VerifyPurchaseParams,
        context,
    ): Promise<boolean> => {
      // Check authentication
      if (!context.auth) {
        console.warn("verifyPurchase called when not authenticated");
        throw new HttpsError(
            "unauthenticated",
            "Request was not authenticated.",
        );
      }
      // Get the product data from the map
      const productData = productDataMap[data.productId];
      // If it was for an unknown product, do not process it.
      if (!productData) {
        console.warn(`verifyPurchase called for an unknown product ("${data.productId}")`);
        return false;
      }
      // If it was for an unknown source, do not process it.
      if (!purchaseHandlers[data.source]) {
        console.warn(`verifyPurchase called for an unknown source ("${data.source}")`);
        return false;
      }
      // Process the purchase for the product
      return purchaseHandlers[data.source].verifyPurchase(
          context.auth.uid,
          productData,
          data.verificationData,
      );
    });

It's time to define the cloud function:

First, check whether the user making the function call is authenticated. You only want to process purchases for authenticated users, because otherwise, you can't tie their purchases to them.

      // Check authentication
      if (!context.auth) {
        console.warn("verifyPurchase called when not authenticated");
        throw new HttpsError(
            "unauthenticated",
            "Request was not authenticated.",
        );
      }

Next, find the product data for the product that you are verifying. In this case, use a simple hardcoded map that you'll add in a bit, but you would probably retrieve this data from your database in a real production application.

If the product is unknown, return false to the Flutter app, because you don't validate unknown products:

      // Get the product data from the map
      const productData = productDataMap[data.productId];
      // If it was for an unknown product, do not process it.
      if (!productData) {
        console.warn(`verifyPurchase called for an unknown product ("${data.productId}")`);
        return false;
      }

Then, check whether the source store (App Store or Google Play) is a source you support. If not, return false:

      // If it was for an unknown source, do not process it.
      if (!purchaseHandlers[data.source]) {
        console.warn(`verifyPurchase called for an unknown source ("${data.source}")`);
        return false;
      }

Finally, pass the rest of the verification to the purchase handlers that you created:

      // Process the purchase for the product
      return purchaseHandlers[data.source].verifyPurchase(
          context.auth.uid,
          productData,
          data.verificationData,
      );

Define products

Because you didn't initialize the productDataMap used in the previous step, do that now. Open functions/src/products.ts, and add the following:

functions/src/products.ts

export const productDataMap: { [productId: string]: ProductData } = {
  "dash_consumable_2k": {
    productId: "dash_consumable_2k",
    type: "NON_SUBSCRIPTION",
  },
  "dash_upgrade_3d": {
    productId: "dash_upgrade_3d",
    type: "NON_SUBSCRIPTION"
  },
  "dash_subscription_doubler": {
    productId: "dash_subscription_doubler",
    type: "SUBSCRIPTION",
  },
};

Here, you map the ID for each purchasable item to its data, so you can find out more about the item based on its identifier. Now, import productDataMap in index.ts, and you should be good to go.

Verify Android purchases: Implement the purchase hander

Next, continue implementing the Google Play purchase handler.

Google already provides JavaScript libraries for interacting with the APIs you need to verify purchases, so you start by importing these, as well as the credentials you created, at the top of the google-play.purchase-handler.ts file:

functions/src/google-play.purchase-handler.ts

import {GoogleAuth} from "google-auth-library";
import {androidpublisher_v3 as AndroidPublisherApi} from "googleapis";
import credentials from "./assets/service-account.json";

Next, initialize the Android publisher API client in the constructor:

functions/src/google-play.purchase-handler.ts

export class GooglePlayPurchaseHandler extends PurchaseHandler {
  private androidPublisher: AndroidPublisherApi.Androidpublisher;

  constructor(private iapRepository: IapRepository) {
    super();
    this.androidPublisher = new AndroidPublisherApi.Androidpublisher({
      auth: new GoogleAuth(
          {
            credentials,
            scopes: ["https://www.googleapis.com/auth/androidpublisher"],
          }),
    });
  }
  // omitted
}

Now, implement the handler for non-subscription-type purchases:

functions/src/google-play.purchase-handler.ts

export class GooglePlayPurchaseHandler extends PurchaseHandler {
  // omitted
  async handleNonSubscription(
      userId: string | null,
      productData: ProductData,
      token: string,
  ): Promise<boolean> {
    try {
      // Verify purchase with Google
      const response = await this.androidPublisher.purchases.products.get(
          {
            packageName: ANDROID_PACKAGE_ID,
            productId: productData.productId,
            token,
          },
      );
      // Make sure an order id exists
      if (!response.data.orderId) {
        console.error("Could not handle purchase without order id");
        return false;
      }
      // Construct purchase data for db updates
      const purchaseData: Omit<NonSubscriptionPurchase, "userId"> =
          {
            type: "NON_SUBSCRIPTION",
            iapSource: "google_play",
            orderId: response.data.orderId,
            productId: productData.productId,
            purchaseDate: firestore.Timestamp.fromMillis(parseInt(response.data.purchaseTimeMillis ?? "0", 10)),
            status: [
              "COMPLETED",
              "CANCELLED",
              "PENDING",
            ][response.data.purchaseState ?? 0] as NonSubscriptionStatus,
          };
      // Update the database
      try {
        if (userId) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
          await this.iapRepository
              .createOrUpdatePurchase({
                ...purchaseData,
                userId,
              } as Purchase);
        } else {
        // If we do not know the user id, a previous entry must already exist,
        // and thus we'll only update it.
          await this.iapRepository.updatePurchase(purchaseData);
        }
      } catch (e) {
        console.log("Could not create or update purchase", {orderId: response.data.orderId, productId: productData.productId});
      }
      return true;
    } catch (e) {
      console.error(e);
      return false;
    }
  }
  // omitted
}

You can update the subscription purchase handler in a similar way:

functions/src/google-play.purchase-handler.ts

export class GooglePlayPurchaseHandler extends PurchaseHandler {
  // omitted
  async handleSubscription(
      userId: string | null,
      productData: ProductData,
      token: string,
  ): Promise<boolean> {
    try {
      // Verify the purchase with Google
      const response = await this.androidPublisher.purchases.subscriptions.get(
          {
            packageName: ANDROID_PACKAGE_ID,
            subscriptionId: productData.productId,
            token,
          },
      );
      // Make sure an order id exists
      if (!response.data.orderId) {
        console.error("Could not handle purchase without order id");
        return false;
      }
      // If a subscription suffix is present (..#) extract the orderId.
      let orderId = response.data.orderId;
      const orderIdMatch = /^(.+)?[.]{2}[0-9]+$/g.exec(orderId);
      if (orderIdMatch) {
        orderId = orderIdMatch[1];
      }
      console.log({
        rawOrderId: response.data.orderId,
        newOrderId: orderId,
      });
      // Construct purchase data for db updates
      const purchaseData: Omit<SubscriptionPurchase, "userId"> = {
        type: "SUBSCRIPTION",
        iapSource: "google_play",
        orderId: orderId,
        productId: productData.productId,
        purchaseDate: firestore.Timestamp.fromMillis(parseInt(response.data.startTimeMillis ?? "0", 10)),
        expiryDate: firestore.Timestamp.fromMillis(parseInt(response.data.expiryTimeMillis ?? "0", 10)),
        status: [
          "PENDING", // Payment pending
          "ACTIVE", // Payment received
          "ACTIVE", // Free trial
          "PENDING", // Pending deferred upgrade/downgrade
          "EXPIRED", // Expired or cancelled
        ][response.data.paymentState ?? 4] as SubscriptionStatus,
      };
      try {
        if (userId) {
          // If we know the userId,
          // update the existing purchase or create it if it does not exist.
          await this.iapRepository
              .createOrUpdatePurchase({
                ...purchaseData,
                userId,
              } as Purchase);
        } else {
          // If we do not know the user id, a previous entry must already exist,
          // and thus we'll only update it.
          await this.iapRepository.updatePurchase(purchaseData);
        }
      } catch (e) {
        console.log("Could not create or update purchase", {orderId, productId: productData.productId});
      }
      console.log("FINISHED VERIFY PURCHASE FOR SUBSCRIPTION");
      return true;
    } catch (e) {
      console.error(e);
      return false;
    }
  }
}

Your Google Play purchases should now be verified and stored in the database.

Next, move on to App Store purchases for iOS.

Verify iOS purchases: Implement the purchase handler

For verifying purchases with the App Store, a third-party JavaScript library exists named node-apple-receipt-verify that makes the process easier.

Start by importing the JavaScript library, as well as the shared secret you put into constants.ts before, at the top of your app-store.purchase-handler.ts file:

functions/src/app-store.purchase-handler.ts

import * as appleReceiptVerify from "node-apple-receipt-verify";
import {APP_STORE_SHARED_SECRET} from "./constants";

Next, configure the API client in the constructor:

functions/src/app-store.purchase-handler.ts

export class AppStorePurchaseHandler extends PurchaseHandler {
  constructor(private iapRepository: IapRepository) {
    super();
    appleReceiptVerify.config({
      verbose: false,
      secret: APP_STORE_SHARED_SECRET,
      extended: true,
      environment: ["sandbox"], // Optional, defaults to ['production'],
      excludeOldTransactions: true,
    });
  }
  // omitted
}

Now, unlike the Google Play APIs, the App Store uses the same API endpoints for both subscriptions and non-subscriptions. This means that you can use the same logic for both handlers. Merge them together so they call the same implementation:

functions/src/app-store.purchase-handler.ts

export class AppStorePurchaseHandler extends PurchaseHandler {
  // omitted
  async handleNonSubscription(
      userId: string,
      productData: ProductData,
      token: string,
  ): Promise<boolean> {
    return this.handleValidation(userId, token);
  }

  async handleSubscription(
      userId: string,
      productData: ProductData,
      token: string,
  ): Promise<boolean> {
    return this.handleValidation(userId, token);
  }

  private async handleValidation(
      userId: string,
      token: string,
  ): Promise<boolean> {
    return true;
  }
  // omitted
}

Now, implement handleValidation:

functions/src/app-store.purchase-handler.ts

export class AppStorePurchaseHandler extends PurchaseHandler {
  // omitted
  private async handleValidation(
      userId: string,
      token: string,
  ): Promise<boolean> {
    // Validate receipt and fetch the products
    let products: appleReceiptVerify.PurchasedProducts[];
    try {
      products = await appleReceiptVerify.validate({receipt: token});
    } catch (e) {
      if (e instanceof appleReceiptVerify.EmptyError) {
        // Receipt is valid but it is now empty.
        console.warn(
            "Received valid empty receipt");
        return true;
      } else if (e instanceof
          appleReceiptVerify.ServiceUnavailableError) {
        console.warn(
            "App store is currently unavailable, could not validate");
        // Handle app store services not being available
        return false;
      }
      return false;
    }
    // Process the received products
    for (const product of products) {
      // Skip processing the product if it is unknown
      const productData = productDataMap[product.productId];
      if (!productData) continue;
      // Process the product
      switch (productData.type) {
        case "SUBSCRIPTION":
          await this.iapRepository.createOrUpdatePurchase({
            type: productData.type,
            iapSource: "app_store",
            orderId: product.originalTransactionId,
            productId: product.productId,
            userId,
            purchaseDate: firestore.Timestamp.fromMillis(product.purchaseDate),
            expiryDate: firestore.Timestamp.fromMillis(
                product.expirationDate ?? 0,
            ),
            status: (product.expirationDate ?? 0) <= Date.now() ? "EXPIRED" : "ACTIVE",
          });
          break;
        case "NON_SUBSCRIPTION":
          await this.iapRepository.createOrUpdatePurchase({
            type: productData.type,
            iapSource: "app_store",
            orderId: product.originalTransactionId,
            productId: product.productId,
            userId,
            purchaseDate: firestore.Timestamp.fromMillis(product.purchaseDate),
            status: "COMPLETED",
          });
          break;
      }
    }
    return true;
  }
  // omitted
}

You might notice that originalTransactionId is a missing property on product. This is because, as of the writing of this codelab, this property is missing from the type definitions of the node-apple-receipt-verify library. To fix this, you're going to add our own type definition for it at the top of the file, below the imports:

functions/src/app-store.purchase-handler.ts

import // omitted

// Add typings for missing property in library interface.
declare module "node-apple-receipt-verify" {
  interface PurchasedProducts {
    originalTransactionId: string;
  }
}

export class AppStorePurchaseHandler // omitted

Your App Store purchases should now be verified and stored in the database!

Deploy the backend

At this point, you can run firebase deploy to put your new cloud functions into action.

10. Keep track of purchases

There are two ways of keeping track of purchases. The recommended way is to track your users' purchases in the backend service. This is because your backend can respond to events from the store and thus is less prone to running into outdated information due to caching, as well as being less susceptible to being tampered with. The other way is by keeping track of purchases on the device itself.

First, set up the processing of store events on the backend with the Firebase backend you've been building. Later, you'll see the available options for tracking purchases on the device itself.

Process store events on the backend

Stores have the ability to inform your backend of any billing events that happen, such as when subscriptions renew. You can process these events in your backend to keep the purchases in your database current. In this section, set this up for both the Google Play Store and the Apple App Store.

Process Google Play billing events

Google Play provides billing events through what they call a cloud pub/sub topic. These are essentially message queues that messages can be published on, as well as consumed from.

Because this is functionality specific to Google Play, you include this functionality in the GooglePlayPurchaseHandler.

Start by opening up functions/src/google-play.purchase-handler.ts, and adding a cloud functions instance below the import:

functions/src/google-play.purchase-handler.ts

import * as Functions from "firebase-functions";
const functions = Functions.region(CLOUD_REGION);

Then, define the cloud function to consume pub/sub events as follows:

functions/src/google-play.purchase-handler.ts

// omitted
export class GooglePlayPurchaseHandler extends PurchaseHandler {
  // omitted
  handleServerEvent = functions.pubsub.topic(GOOGLE_PLAY_PUBSUB_BILLING_TOPIC)
      .onPublish(async (message) => {
        // Define the event
        // https://developer.android.com/google/play/billing/rtdn-reference
        type GooglePlayOneTimeProductNotification = {
          "version": string;
          "notificationType": number;
          "purchaseToken": string;
          "sku": string;
        }
        type GooglePlaySubscriptionNotification = {
          "version": string;
          "notificationType": number;
          "purchaseToken": string;
          "subscriptionId": string;
        }
        type GooglePlayTestNotification = {
          "version": string;
        }
        type GooglePlayBillingEvent = {
          "version": string;
          "packageName": string;
          "eventTimeMillis": number;
          "oneTimeProductNotification": GooglePlayOneTimeProductNotification;
          "subscriptionNotification": GooglePlaySubscriptionNotification;
          "testNotification": GooglePlayTestNotification;
        }
        let event: GooglePlayBillingEvent;
        // Parse the event data
        try {
          event = JSON.parse(new Buffer(message.data, "base64").toString("ascii"));
        } catch (e) {
          console.error("Could not parse Google Play billing event", e);
          return;
        }
        // Skip test events
        if (event.testNotification) return;
        // Extract event data
        const {purchaseToken, subscriptionId, sku} = {
          ...event.subscriptionNotification,
          ...event.oneTimeProductNotification,
        };
        // Get the product for this event
        const productData = productDataMap[subscriptionId ?? sku];
        // Skip products that are unknown
        if (!productData) return;
        // Skip products that do not match the notification type
        const notificationType = subscriptionId ? "SUBSCRIPTION" : sku ? "NON_SUBSCRIPTION" : null;
        if (productData.type !== notificationType) return;
        // Handle notifications
        switch (notificationType) {
          case "SUBSCRIPTION":
            await this.handleSubscription(null, productData, purchaseToken);
            break;
          case "NON_SUBSCRIPTION":
            await this.handleNonSubscription(null, productData, purchaseToken);
            break;
        }
      });
}

As you can see, you can use the standard verification logic you wrote earlier to process the tokens you get from these events.

Next, export this function from index.ts so that it registers as a cloud function:

functions/src/index.ts

// omitted
// Handling of PlayStore server-to-server events
export const handlePlayStoreServerEvent =
    (purchaseHandlers.google_play as GooglePlayPurchaseHandler)
        .handleServerEvent;

Process App Store billing events

Next, do the same for the App Store billing events. The App Store uses a simple webhook rather than a cloud pub/sub topic, so you can just create a regular https cloud function for it.

Start by opening up functions/src/app-store.purchase-handler.ts, and adding a cloud functions instance below the import: functions/src/app-store.purchase-handler.ts

import * as Functions from "firebase-functions";
const functions = Functions.region(CLOUD_REGION);

Then, define the cloud function for the webhook as follows:

functions/src/app-store.purchase-handler.ts

// omitted
export class AppStorePurchaseHandler extends PurchaseHandler {
  // omitted
  handleServerEvent = functions.https.onRequest(async (
      req,
      res,
  ) => {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    type ReceiptInfo = {
      productId: string;
      expiresDateMs: string;
      originalTransactionId: string;
    };
    const eventData: {
      notificationType: "CANCEL" | "DID_CHANGE_RENEWAL_PREF" | "DID_CHANGE_RENEWAL_STATUS" | "DID_FAIL_TO_RENEW" | "DID_RECOVER" | "DID_RENEW" | "INITIAL_BUY" | "INTERACTIVE_RENEWAL" | "PRICE_INCREASE_CONSENT" | "REFUND" | "REVOKE";
      password: string;
      environment: "Sandbox" | "PROD",
      unifiedReceipt: {
        "environment": "Sandbox" | "Production",
        latestReceiptInfo: Array<ReceiptInfo>,
      };
    } = camelCaseKeys(req.body, {deep: true});
    // Decline events where the password does not match the shared secret
    if (eventData.password !== APP_STORE_SHARED_SECRET) {
      res.sendStatus(403);
      return;
    }
    // Only process events where expiration changes are likely to occur
    if (!["CANCEL", "DID_RENEW", "DID_FAIL_TO_RENEW", "DID_CHANGE_RENEWAL_STATUS",
      "INITIAL_BUY", "INTERACTIVE_RENEWAL", "REFUND",
      "REVOKE"].includes(eventData.notificationType)) {
      res.sendStatus(200);
      return;
    }
    // Find latest receipt for each original transaction
    const latestReceipts: ReceiptInfo[] = Object.values(
        groupBy(eventData.unifiedReceipt.latestReceiptInfo, "originalTransactionId")
    ).map((group) => group
        .reduce((acc: ReceiptInfo, e: ReceiptInfo) =>
                    (!acc || e.expiresDateMs >= acc.expiresDateMs) ? e : acc
        )
    );
    // Process receipt items
    for (const iap of latestReceipts) {
      const productData = productDataMap[iap.productId];
      // Skip products that are unknown
      if (!productData) continue;
      // Update products in firestore
      switch (productData.type) {
        case "SUBSCRIPTION":
          try {
            await this.iapRepository.updatePurchase({
              iapSource: "app_store",
              orderId: iap.originalTransactionId,
              expiryDate: Timestamp.fromMillis(parseInt(iap.expiresDateMs, 10)),
              status: Date.now() >= parseInt(iap.expiresDateMs, 10) ? "EXPIRED" : "ACTIVE",
            });
          } catch (e) {
            console.log("Could not patch purchase", {originalTransactionId: iap.originalTransactionId, productId: iap.productId});
          }
          break;
        case "NON_SUBSCRIPTION":
          // Nothing to update yet about non-subscription purchases
          break;
      }
    }
    res.status(200).send();
  });
}

Next, export this function from index.ts so that it registers as a cloud function:

functions/src/index.ts

// omitted
// Handling of AppStore server-to-server events
export const handleAppStoreServerEvent =
    (purchaseHandlers.app_store as AppStorePurchaseHandler)
        .handleServerEvent;

Expiring Subscriptions

Sometimes, events from stores come in earlier or later than expected. As such, you cannot expect events to come in right as a subscription expires for you to update your database. Therefore, it is a good idea to have a scheduled task that finds all expired subscriptions that have not yet had their status changed to EXPIRED, and update them where necessary.

For this, the IapRepository contains an expireSubscriptions function.

To schedule this method to be called, for example every minute, export the following function from your index.ts:

functions/src/index.ts

// omitted
export const expireSubscriptions = functions.pubsub.schedule("every 1 mins")
    .onRun(() => iapRepository.expireSubscriptions());

Google Play setup

You've already written the code to consume billing events from the pub/sub topic, but you haven't created the pub/sub topic, nor are you publishing any billing events. It's time to set this up.

First, create a pub/sub topic:

  1. Visit the Cloud Pub/Sub page on the Google Cloud Console.
  2. Ensure that you're on your Firebase project, and click + Create Topic. d02defd38eab0aea.png
  3. Give the new topic a name, identical to the value set for GOOGLE_PLAY_PUBSUB_BILLING_TOPIC in constants.ts. In this case, name it play_billing. If you choose something else, make sure to update constants.ts. Create the topic. 5fceef8e67f5c95f.png
  4. In the list of your pub/sub topics, click the three vertical dots for the topic you just created, and click View permissions. f1872b8e715a660b.png
  5. In the sidebar on the right, choose Add member.
  6. Here, add google-play-developer-notifications@system.gserviceaccount.com, and grant it the role of Pub/Sub Publisher. c21076b5eddc4de3.png
  7. Save the permission changes.
  8. Open the Play Console again, and choose your app from the All Apps list.
  9. Scroll down and go to Monetize > Monetization Setup.
  10. Fill in the full topic and save your changes. 97f434d335d0557c.png

All Google Play billing events will now be published on the topic.

App Store setup

Next, set up the App Store:

  1. Get the public URL for the cloud function you have set up.
  2. Make sure that the cloud function is deployed by running firebase deploy.
  3. Open the Firebase console for your project, and go to Functions.

Here, you can find the webhook URL of your handleAppStoreServerEvent. 3ae97f8fa6e766ab.png

  1. Log in to App Store Connect, and select your app.
  2. Go to General > App Information.
  3. Enter the webhook URL you copied from the Firebase console into the URL for App Store Server Notifications field, and save your changes. c112e9a9d1193f21.png

Track purchases on the device

The most secure way to track your purchases is on the server side because the client is hard to secure, but you need to have some way to get the information back to the client so the app can act on the subscription status information. By storing the purchases in Firestore, you can easily sync the data to the client and keep it updated automatically.

You already included IAPRepo in the app, which is the Firestore repository that contains all of the user's purchase data in List<PastPurchase> purchases. The repository also contains hasActiveSubscription, which is true when there is a purchase with productId storeKeySubscription with a status that is not expired. When the user isn't logged in, the list is empty.

lib/repo/iap_repo.dart

  void updatePurchases() {
    _purchaseSubscription?.cancel();
    var user = _user;
    if (user == null) {
      purchases = [];
      hasActiveSubscription = false;
      hasUpgrade = false;
      return;
    }
    var purchaseStream = _firestore
        .collection('purchases')
        .where('userId', isEqualTo: user.uid)
        .snapshots();
    _purchaseSubscription = purchaseStream.listen((snapshot) {
      purchases = snapshot.docs.map((DocumentSnapshot document) {
        var data = document.data();
        if (data == null) throw Exception('Queried document has no data');
        return PastPurchase.fromJson(data);
      }).toList();

      hasActiveSubscription = purchases.any((element) =>
          element.productId == storeKeySubscription &&
          element.status != Status.expired);

      hasUpgrade = purchases.any(
        (element) => element.productId == storeKeyUpgrade,
      );

      notifyListeners();
    });
  }

All purchase logic is in the DashPurchases class and is where subscriptions should be applied or removed. So, add the iapRepo as a property in the class and assign the iapRepo in the constructor. Next, directly add a listener in the constructor, and remove the listener in the dispose() method. At first, the listener can just be an empty function. Because the IAPRepo is a ChangeNotifier and you call notifyListeners() every time the purchases in Firestore change, the purchasesUpdate()method is always called when the purchased products change.

lib/logic/dash_purchases.dart

  IAPRepo iapRepo;

  DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
    final purchaseUpdated =
        iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    iapRepo.addListener(purchasesUpdate);
    loadPurchases();
  }

  @override
  void dispose() {
    iapRepo.removeListener(purchasesUpdate);
    _subscription.cancel();
    super.dispose();
  }

  void purchasesUpdate() {
    //TODO manage updates
  }

Next, supply the IAPRepo to the constructor in main.dart. You can get the repository by using context.read because it's already created in a Provider.

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
            context.read<IAPRepo>(),
          ),
          lazy: false,
        ),

Next, write the code for the purchaseUpdate() function. In dash_counter.dart, the applyPaidMultiplier and removePaidMultiplier methods set the multiplier to 10 or 1, respectively, so you don't have to check whether the subscription is already applied. When the subscription status changes, you also update the status of the purchasable product so you can show in the purchase page that it's already active. Set the _beautifiedDashUpgrade property based on whether the upgrade is bought.

lib/logic/dash_purchases.dart

  void purchasesUpdate() {
    var subscriptions = <PurchasableProduct>[];
    var upgrades = <PurchasableProduct>[];
    // Get a list of purchasable products for the subscription and upgrade.
    // This should be 1 per type.
    if (products.isNotEmpty) {
      subscriptions = products
          .where((element) => element.productDetails.id == storeKeySubscription)
          .toList();
      upgrades = products
          .where((element) => element.productDetails.id == storeKeyUpgrade)
          .toList();
    }
    
    // Set the subscription in the counter logic and show/hide purchased on the 
    // purchases page.
    if (iapRepo.hasActiveSubscription) {
      counter.applyPaidMultiplier();
      subscriptions.forEach(
          (element) => _updateStatus(element, ProductStatus.purchased));
    } else {
      counter.removePaidMultiplier();
      subscriptions.forEach(
          (element) => _updateStatus(element, ProductStatus.purchasable));
    }

    // Set the Dash beautifier and show/hide purchased on 
    // the purchases page.
    if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
      _beautifiedDashUpgrade = iapRepo.hasUpgrade;
      upgrades.forEach((element) => _updateStatus(
          element,
          _beautifiedDashUpgrade
              ? ProductStatus.purchased
              : ProductStatus.purchasable));
      notifyListeners();

    }
  }

  void _updateStatus(PurchasableProduct product, ProductStatus status) {
    if (product.status != ProductStatus.purchased) {
      product.status = ProductStatus.purchased;
      notifyListeners();
    }
  }

You have now ensured that the subscription and upgrade status is always current in the backend service and synchronized with the app. The app acts accordingly and applies the subscription and upgrade features to your Dash clicker game.

11. All done!

Congratulations!!! You have completed the codelab. You can find the completed code for this codelab in the android_studio_folder.pngcomplete folder.

To learn more, try the other Flutter codelabs.