Irbisa · Cheatsheet
In-App Purchases in Flutter
in_app_purchase and RevenueCat, product types, the purchase stream, restores, subscription states, trials, server notifications, store rules, testing
Your app sells a premium plan, a printed photo book and a booked yoga class. Which must go through App Store and Google Play billing, and which can use Stripe?
What is the difference between consumable, non-consumable and auto-renewable subscription products, and what does each one oblige the app to do?
Walk through a purchase with the
in_app_purchaseplugin:queryProductDetails,buyNonConsumable,purchaseStreamandcompletePurchase. What does each step do?final iap = InAppPurchase.instance;// In main(), before runApp: an update with no listener is lostvoid startPurchases() => iap.purchaseStream.listen(_onPurchases); Future<ProductDetails?> loadPremium() async { if (!await iap.isAvailable()) return null; // no store on this device…queryProductDetailsreturns every id innotFoundIDson a real device. What do you check on each store?Find the bug: a purchase made just before the app was killed never unlocks anything, and Google Play refunds it three days later.
class _PaywallState extends State<Paywall> { late final StreamSubscription<List<PurchaseDetails>> _sub; @override void initState() { super.initState(); _sub = InAppPurchase.instance.purchaseStream.listen((purchases) async { for (final p in purchases.where((p) => p.status == PurchaseStatus.purchased)) { await api.grant(p.verificationData.serverVerificationData); await InAppPurchase.instance.completePurchase(p); } }); } @override void dispose() { _sub.cancel(); super.dispose(); } @override Widget build(BuildContext context) => const PlanList(); // Buy: buyNonConsumable}What does
PurchaseStatus.pendingmean (Ask to Buy, a cash payment), and what should the paywall do while a purchase stays pending?enum PaywallStatus { idle, buying, waiting, owned } final paywallStatus = ValueNotifier(PaywallStatus.idle); Future<void> onPurchase(PurchaseDetails p) async { switch (p.status) {…Find the bug: after a flaky connection, a user who bought 500 coins once has 1,000 in their balance.
// Dio has a retry interceptor: a timed-out request is sent again, up to 3 timesFuture<void> onPurchase(PurchaseDetails p) async { if (p.status != PurchaseStatus.purchased) return; final res = await dio.post('/wallet/add', data: {'coins': 500}); wallet.balance = res.data['balance'] as int; await InAppPurchase.instance.completePurchase(p);}How do you implement Restore Purchases on iOS and Android, and why does App Review reject a paid app without a restore option?
Future<void> onRestoreTapped() async { final iap = InAppPurchase.instance; if (Platform.isIOS) { try { // AppStore.sync(): may ask for the Apple Account password, so only on a tap await iap.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>().sync();…How does the app know at launch that the user is still subscribed, and what should it show offline or when your server is down?
class EntitlementRepository { EntitlementRepository(this._api, this._cache); final Api _api; final EntitlementCache _cache; /// First frame, no network: the server's last answer, until its period ends.…in_app_purchaseor RevenueCat'spurchases_flutter: what does RevenueCat take off your hands, and what does it cost you?Future<void> initPurchases(String userId) async { final config = PurchasesConfiguration( Platform.isIOS ? 'appl_xxx' : 'goog_xxx', // public SDK keys, safe to ship )..appUserID = userId; // your account id: the same customer on every platform await Purchases.configure(config); Purchases.addCustomerInfoUpdateListener(_apply); // renewals, refunds, restores…This paywall builds its price label from
rawPrice. What do users outside the US see, and how should the price be shown?// yearly: the ProductDetails for 'pro_yearly' from queryProductDetailsWidget yearlyPriceLabel(ProductDetails yearly) { final perMonth = yearly.rawPrice / 12; return Text('Just \$${perMonth.toStringAsFixed(2)} a month');}How do you offer a 7-day free trial only to users who are eligible for it, on both the App Store and Google Play?
/// The entry to sell with a trial, or null: then the paywall shows no trial copy.Future<ProductDetails?> trialProduct(List<ProductDetails> details) async { final play = details.whereType<GooglePlayProductDetails>().toList(); if (play.isNotEmpty) { // Android: one entry per offer, and Play lists only offers this user may buy return play.where((d) {…A subscriber's card expires before renewal. Which states (grace period, billing retry, account hold, expired) do they pass through, and what does the app show in each?
Renewals, cancellations and refunds happen while your app is closed. How does your backend hear about them, and why can't the app be the one that reports them?
How do you test purchases without spending money: sandbox accounts, StoreKit configuration files, Play license testers, and what will none of them reproduce?
A user subscribes on their iPhone, then signs in on your Android app and website. Where does the subscription live, and how does each platform learn about it?
// accountId: a UUID your backend gives the user at sign-up and never changesFuture<bool> subscribe(ProductDetails plan, String accountId) => InAppPurchase.instance.buyNonConsumable( purchaseParam: PurchaseParam( productDetails: plan, // iOS: becomes StoreKit's appAccountToken (dropped silently unless a UUID);…How do you move a subscriber from monthly to yearly on both stores, and what happens to the old subscription and the money already paid?
Future<void> switchToYearly(ProductDetails yearly, PurchaseDetails current) async { final iap = InAppPurchase.instance; if (current is GooglePlayPurchaseDetails) { // Android: name the purchase being replaced, or Play may sell a second one await iap.buyNonConsumable( purchaseParam: GooglePlayPurchaseParam(…Product wants to test three paywall designs and two prices. What has to exist in the app and on the server, and why can't you change a live product's price per user?