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:
- A repeatable purchase option for 2000 Dashes at once.
- A one-time upgrade purchase to make the old style Dash into a modern style Dash.
- 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 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 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.
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.inapppurchase
. 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.
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.
You can now close Xcode and go back to Android Studio to finish the configuration for Android. To do so open the build.gradle
file under android/app,
and change your applicationId
(on line 37 in the screenshot below) 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, however keeping them identical is less error prone and therefore in this codelab we will also use identical identifiers.
3. Initialize the plugin
In this part of the codelab you'll initialize the plugin so that our first APK build will contain the com.android.vending.BILLING
permission. This permission is required to add in-app products in the Google Play Store.
Add dependency in pubspec
First, add in_app_purchase
to the pubspec by adding in_app_purchase: ^2.0.1
to the dependencies in your pubspec:
pubspec.yaml
dependencies:
..
cloud_firestore: ^3.1.5
cloud_functions: ^3.2.4
firebase_auth: ^3.3.4
firebase_core: ^1.10.6
google_sign_in: ^5.2.1
in_app_purchase: ^2.0.1
..
Click pub get to download the package or run flutter pub get
in the command line. This will ensure the com.android.vending.BILLING
permission is added to the android/app/src/main/AndroidManifest.xml
file in your Flutter application.
4. Set up the App Store
To set up in-app purchases and test them 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.
Paid Apps Agreements
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.
You will see agreements here for free and paid apps. The status of free apps 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.
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.
Register App ID
Create a new identifier in the Apple developer portal.
Choose App IDs
Choose App
Provide some description and set the bundle ID to match the bundle ID to the same value as previously set in XCode.
For more guidance about how to create a new app ID, see the Developer Account Help .
Creating a new app
Create a new app in App Store Connect with your unique bundle identifier.
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.
Now you can set up your sandbox user on your iPhone by going to Settings > App Store > Sandbox-account.
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.
Go to In-App Purchases > Manage.
Create your in-app purchases with the specified IDs:
- 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.
- 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.
- 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:
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
.
Next, enter the subscription duration and the localizations. Name this subscription Jet Engine
with the description Doubles your clicks
. Click Save.
After you've clicked the Save button, add a subscription price. Pick any price you desire.
You should now see the three purchases in the list of purchases:
5. 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:
- Open the Play Console.
- Select All apps > Create app.
- 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.
- Specify that your application is a game. You can change this later.
- Specify whether your application is free or paid.
- Add an email address that Play Store users can use to contact you about this application.
- Complete the Content guidelines and US export laws declarations.
- 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.
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 the keystore information from your properties file before the android
block:
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 and the output can be found at <your app dir>/build/app/outputs/bundle/release/
From the dashboard in the Google Play Console, go to Release > Testing > Closed testing, and create a new, closed 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.
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:
- To the specific test track (Internal testing)
- 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.
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:
- Go back to the All apps view of the Google Play Console.
- Go to Settings > License testing.
- Add the same email addresses of the testers who need to be able to test in-app purchases.
- Set License response to
RESPOND_NORMALLY
. - 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.
- Go to the Google Play Console, and select your application.
- Go to Monetize > Products > In-app products.
- Click Create product
- Enter all the required information for your product. Make sure the product ID matches the ID that you intend to use exactly.
- Click Save.
- Click Activate.
- Repeat the process for the non-consumable "upgrade" purchase.
Next, add the subscription:
- Go to the Google Play Console, and select your application.
- Go to Monetize > Products > Subscriptions.
- Click Create subscription
- Enter all the required information for your subscription. Make sure the product ID matches the ID you intend to use exactly.
- Click Save
Your purchases should now be set up in the Play Console.
6. 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.
- From the Firebase dashboard, go to Authentication and enable it, if needed.
- Go to the Sign-in method tab, and enable the Google sign-in provider.
Because you'll also use Firebases's Firestore database and cloud functions, enable these too.
Set up Firebase for Android
From the Firebase dashboard, go to Project Overview, choose + Add App from the header, and add an Android app.
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.
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 then 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.
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, open the ios/Runnder.xcworkspace
with Xcode
. Or with your IDE of choice.
On VSCode right click on the ios/
folder and then open in xcode
.
On Android Studio right click on the ios/
folder then click on flutter
followed by the open iOS module in Xcode
option.
Right click Runner from the left-hand side project navigation within Xcode and select add files to "Runner"
, as seen below:
Select the GoogleService-info.plist
file you downloaded, and ensure the Copy items if needed
checkbox is enabled:
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:
- Get the value for
REVERSED_CLIENT_ID
from theGoogleService-Info.plist
file, without the<string>..</string>
element surrounding it. - Replace the value in both your
ios/Runner/Info-Debug.plist
andios/Runner/Info-Release.plist
files under theCFBundleURLTypes
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.
7. Listen to purchase updates
In this part of the codelab you'll prepare the app for purchasing the products. This process includes listening to purchase updates and errors after the app starts.
Listen to purchase updates
In main.dart,
find the widget MyHomePage
that has a Scaffold
with a BottomNavigationBar
containing two pages. This page also creates three Provider
s for DashCounter
, DashUpgrades,
and 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(const 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!
8. 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
import '../constants.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);
for (var element in response.notFoundIDs) {
debugPrint('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
field 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.
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);
}
}
9. Set up the backend
Before moving on to tracking and verifying purchases, set up a Firebase backend to support doing so.
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.
Link your project
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.
- Go to the Google Play Console, and start from the All apps page.
- Go to Setup > API access.
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.
- Find the section where you can define service accounts, and click Create new service account.
- Click the Google Cloud Platform link in the dialog that pops up.
- 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.
- After selecting your project, click + Create Service Account in the top menu bar.
- 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.
- Assign the service account the Editor role.
- 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.
- Click Grant access for your new service account.
- 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.
- Click Invite user.
- 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.
- Create a new JSON key and download it.
- Rename the downloaded file to
service-account.json,
and move it into thefunctions/src/assets/
directory.
One more thing we need to do is 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:
- Open App Store Connect.
- Go to My Apps, and select your app.
- In the sidebar navigation, go to In-App Purchases > Manage.
- At the top right of the list, click App-Specific Shared Secret.
- Generate a new secret, and copy it.
- Open
functions/src/constants.ts,
and replace the value ofAPP_STORE_SHARED_SECRET
with the shared secret you just generated.
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
10. 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 {
const PurchasePage({Key? key}) : super(key: key);
@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 should 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.
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";
import {CLOUD_REGION} from "./constants";
import {IapRepository, IAPSource} from "./iap.repository";
import {PurchaseHandler} from "./purchase-handler";
import {GooglePlayPurchaseHandler} from "./google-play.purchase-handler";
import {AppStorePurchaseHandler} from "./app-store.purchase-handler";
//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
import {HttpsError} from "firebase-functions/lib/providers/https";
//omitted
// 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> => {
// To be implemented
});
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.
functions/src/index.ts
import {productDataMap} from "./products";
//omitted
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
import { firestore } from "firebase-admin/lib/firestore";
import { ANDROID_PACKAGE_ID } from "./constants";
import { IapRepository, NonSubscriptionPurchase, NonSubscriptionStatus, Purchase, SubscriptionPurchase, SubscriptionStatus } from "./iap.repository";
import { ProductData } from "./products";
import { PurchaseHandler } from "./purchase-handler";
// omitted
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
import { SubscriptionPurchase, SubscriptionStatus } from "./iap.repository";
// omitted
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
import { firestore } from "firebase-admin/lib/firestore";
import { ProductData, productDataMap } from "./products";
// omitted
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.
11. 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 {CLOUD_REGION} from "./constants";
import * as Functions from "firebase-functions";
const functions = Functions.region(CLOUD_REGION);
// omitted
Then, define the cloud function to consume pub/sub
events as follows:
functions/src/google-play.purchase-handler.ts
import {GOOGLE_PLAY_PUBSUB_BILLING_TOPIC} from "./constants";
import {productDataMap} from "./products";
// 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 {CLOUD_REGION} from "./constants";
import * as Functions from "firebase-functions";
const functions = Functions.region(CLOUD_REGION);
// omitted
Then, define the cloud function for the webhook as follows:
functions/src/app-store.purchase-handler.ts
import camelCaseKeys from "camelcase-keys";
import {groupBy} from "lodash";
// 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: firestore.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:
- Visit the Cloud Pub/Sub page on the Google Cloud Console.
- Ensure that you're on your Firebase project, and click + Create Topic.
- Give the new topic a name, identical to the value set for
GOOGLE_PLAY_PUBSUB_BILLING_TOPIC
inconstants.ts
. In this case, name itplay_billing
. If you choose something else, make sure to updateconstants.ts
. Create the topic. - In the list of your pub/sub topics, click the three vertical dots for the topic you just created, and click View permissions.
- In the sidebar on the right, choose Add principal.
- Here, add
google-play-developer-notifications@system.gserviceaccount.com
, and grant it the role of Pub/Sub Publisher. - Save the permission changes.
- Copy the Topic name of the topic you've just created.
- Open the Play Console again, and choose your app from the All Apps list.
- Scroll down and go to Monetize > Monetization Setup.
- Fill in the full topic and save your changes.
All Google Play billing events will now be published on the topic.
App Store setup
Next, set up the App Store:
- Get the public URL for the cloud function you have set up.
- Make sure that the cloud function is deployed by running
firebase deploy
. - Open the Firebase console for your project, and go to Functions.
Here, you can find the webhook URL of your handleAppStoreServerEvent
.
- Log in to App Store Connect, and select your app.
- Go to General > App Information.
- Scroll down to the section App Store Server Notifications.
- Now click on Set Up URL under Sandbox Server URL.
- Note: if you are setting up production please use Production Server URL.
- Enter the webhook URL you copied from the Firebase console into the URL for App Store Server Notifications field, select Version 2 Notifications, and save your changes.
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 the 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();
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.
12. All done!
Congratulations!!! You have completed the codelab. You can find the completed code for this codelab in the complete folder.
To learn more, try the other Flutter codelabs.