Comment ajouter des achats intégrés à une application Flutter
Ajoutez des achats intégrés à une application Flutter avec le plugin in_app_purchase. Configurez les boutiques Apple et Google, validez les transactions, restaurez les achats et testez sans risque.
Une application Flutter qui vend des biens numériques ou des abonnements sur iOS ou Android doit généralement encaisser les paiements via le système d’achats intégrés d’Apple ou via Google Play Billing. Stripe et les tunnels de paiement personnalisés ne sont pas autorisés dans ce cas, et les deux stores prélèvent une commission sur chaque vente.
Si votre application accepte déjà des paiements Stripe sur le web, vous comptez peut-être réutiliser ce tunnel de paiement dans la version mobile. Pour des biens numériques, ce choix se solde généralement par un rejet lors de l’App Review.
La commission standard d’Apple est de 30 %. Dans le cadre du App Store Small Business Program, les développeurs dont les revenus de l’année précédente n’ont pas dépassé 1 million USD paient 15 %, aussi bien sur les applications payantes que sur les achats intégrés. Google n’applique pas de taux unique, et sa page consacrée aux frais de service indique que 99 % des développeurs soumis à des frais bénéficient d’un taux de 15 % ou moins. Stripe reste l’outil adapté pour les biens physiques et les services consommés en dehors de l’application. Les règles sont détaillées dans les App Store Review Guidelines relatives aux achats intégrés d’Apple et dans le règlement Play sur les paiements de Google. Dans l’UE, le Digital Markets Act (DMA) impose aux stores d’autoriser des options de paiement alternatives, et Apple décrit ses mécanismes propres à l’UE sur sa page DMA destinée aux développeurs. Sur la vitrine des États-Unis, les directives d’Apple permettent désormais aussi aux applications d’inclure des boutons et des liens vers des options d’achat externes. Les règles relatives aux liens de paiement externes varient selon les régions et évoluent constamment : consultez donc les directives en vigueur des deux stores avant de concevoir votre parcours.
Cet article présente de bout en bout le plugin officiel in_app_purchase : types de produits, configuration des stores, une classe de service fonctionnelle, les cas d’échec qui font perdre des achats, et les tests. Si vous débutez avec Flutter, commencez par le guide Flutter pour les nouveaux développeurs.
Points clés à retenir
- Les biens numériques vendus dans les applications iOS et Android doivent généralement passer par les systèmes de facturation d’Apple et de Google. Stripe couvre les biens physiques et les services consommés en dehors de l’application.
buyConsumableetbuyNonConsumablerenvoient unFuture<bool>qui indique uniquement que la requête a démarré. Le résultat réel arrive sous forme dePurchaseDetailssurpurchaseStream.- Si
completePurchasen’est pas appelé sur un achat dontpendingCompletePurchasevaut true, Google Play le rembourse au bout de trois jours et l’App Store laisse la transaction inachevée. - Les directives de validation d’Apple exigent un mécanisme de restauration pour les achats restaurables.
- Ne débloquez le contenu qu’après que votre backend a validé
serverVerificationDatavia l’App Store Server API ou la Google Play Developer API.
Consommables, non-consommables et abonnements
Le plugin Flutter in_app_purchase prend en charge trois types de produits : les consommables, les non-consommables et les abonnements. Les consommables utilisent buyConsumable. Les non-consommables et les abonnements utilisent tous deux buyNonConsumable. Le type détermine également ce que votre application doit mémoriser.
| Type | Méthode d’achat | Exemple | Ce que votre application doit suivre | Restaurable ? |
|---|---|---|---|---|
| Consommable | buyConsumable | 100 pièces | Solde, stocké dans votre backend | Non |
| Non-consommable | buyNonConsumable | Déblocage Premium | Indicateur de droit d’accès | Oui |
| Abonnement | buyNonConsumable | Pro mensuel | Date d’expiration et état de renouvellement, issus de la vérification côté serveur | Oui |
Les consommables sont le seul type que le store ne mémorise pas pour vous. Une fois un pack de pièces consommé, votre backend détient le seul enregistrement du solde. Un droit d’accès non-consommable peut toujours être reconstitué par une restauration. L’état d’un abonnement évolue dans le temps au gré des renouvellements, des expirations et des remboursements : lisez-le donc à partir de données vérifiées côté serveur, et non d’un indicateur local mis en cache.
La configuration des stores représente l’essentiel du travail
L’essentiel de l’effort nécessaire pour ajouter des achats intégrés à une application Flutter porte sur App Store Connect et la Play Console. La partie Dart est courte. Tant que les deux stores ne sont pas configurés, queryProductDetails ne renverra rien, aussi correct que soit votre code.
Apple (App Store Connect) :
- Signez le contrat Paid Apps (applications payantes) et renseignez vos informations bancaires et fiscales.
- Créez chaque achat intégré avec un identifiant produit tel que
premium_unlock. Créez les abonnements au sein d’un groupe d’abonnements. - Complétez les métadonnées afin que chaque produit atteigne le statut Ready to Submit (Prêt à soumettre).
Google (Play Console) :
- Téléversez un build sur un canal de test. Le canal de test interne convient.
- Créez les produits. Utilisez les mêmes identifiants que chez Apple, ou établissez une correspondance dans le code.
- Activez-les. Google précise que les produits doivent être publiés avant que quiconque puisse les tester.
Si queryProductDetails place vos identifiants dans notFoundIDs, la cause relève presque toujours de la configuration du store, et non du code Dart. Les causes typiques sont un identifiant qui ne correspond pas, un produit inactif, un contrat Paid Apps non signé ou l’absence de build téléversé sur un canal de test Play.
Code d’achat intégré Flutter : stream, achat, finalisation
Le parcours d’achat intégré dans Flutter comporte quatre étapes. Abonnez-vous à purchaseStream au démarrage, vérifiez isAvailable, chargez les produits avec queryProductDetails, puis appelez une méthode d’achat. Traitez chaque résultat dans le listener du stream.
Ajoutez la dépendance :
dependencies:
in_app_purchase: ^3.3.1
Le package in_app_purchase prend en charge Android SDK 24+, iOS 13+ et macOS 10.15+. Les implémentations StoreKit et Play Billing sont des packages fédérés officiellement approuvés (endorsed) qui sont intégrés automatiquement : vous n’avez besoin de les ajouter vous-même que si vous utilisez leurs classes spécifiques à chaque plateforme. Sur les plateformes Apple, StoreKit 2 est utilisé par défaut sur les appareils compatibles.
buyConsumable et buyNonConsumable ne renvoient pas d’achat. Voici une erreur fréquente :
// Wrong: the Future resolves to a bool, not a purchase
final result = await iap.buyNonConsumable(purchaseParam: param);
if (result) unlockPremium(); // true only means the sheet opened
La version correcte interprète le booléen comme « requête démarrée » et effectue toute l’attribution dans le listener du stream :
final started = await iap.buyNonConsumable(purchaseParam: param);
if (!started) showError('Could not start purchase');
// Unlocking happens in the purchaseStream handler
Voici le service complet, abonné dans main() avant runApp. Le README du plugin recommande de s’abonner dans le initState de votre application. Le faire dans main() permet d’intervenir encore plus tôt, de sorte qu’un listener existe avant que le plugin ne puisse émettre la moindre mise à jour.
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
class PurchaseService {
PurchaseService._();
static final instance = PurchaseService._();
static const coins100 = 'coins_100';
static const premium = 'premium_unlock';
final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
void start() {
_sub ??= _iap.purchaseStream.listen(
_onPurchases,
onDone: () => _sub?.cancel(),
onError: (Object e) {/* log */},
);
}
Future<List<ProductDetails>> loadProducts() async {
if (!await _iap.isAvailable()) return [];
final res = await _iap.queryProductDetails({coins100, premium});
if (res.notFoundIDs.isNotEmpty) {/* store setup problem */}
return res.productDetails;
}
Future<bool> buy(ProductDetails p) {
final param = PurchaseParam(productDetails: p);
return p.id == coins100
? _iap.buyConsumable(purchaseParam: param)
: _iap.buyNonConsumable(purchaseParam: param);
}
Future<void> restore() => _iap.restorePurchases();
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
for (final p in purchases) {
var finish = true;
switch (p.status) {
case PurchaseStatus.pending:
// show "waiting for approval"
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
final valid = await _verifyOnServer(p); // null = unreachable
if (valid == null) {
finish = false; // redelivered next launch
} else if (valid) {
await _deliver(p);
}
case PurchaseStatus.error:
// show p.error?.message
break;
case PurchaseStatus.canceled:
// no error UI
break;
}
if (finish && p.pendingCompletePurchase) {
await _iap.completePurchase(p);
}
}
}
Future<bool?> _verifyOnServer(PurchaseDetails p) async {
// POST p.verificationData.serverVerificationData,
// p.productID and platform to your backend
return true;
}
Future<void> _deliver(PurchaseDetails p) async {/* credit coins / set flag */}
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
PurchaseService.instance.start();
runApp(const MyApp());
}
_verifyOnServer est un emplacement réservé pour l’appel à votre backend. La méthode renvoie null lorsque le serveur est injoignable : l’achat reste alors non finalisé et est redélivré au prochain lancement. Cette approche n’est sûre que si la situation est résolue dans le délai de trois jours imposé par Android.
| Statut | Action de l’application | Appeler completePurchase ? |
|---|---|---|
pending | Afficher un état d’attente (par exemple, Ask to Buy ou un moyen de paiement différé) | Non, attendre le statut final |
purchased | Vérifier côté serveur, puis livrer | Oui, si pendingCompletePurchase |
restored | Vérifier, puis réattribuer le droit d’accès | Oui, si pendingCompletePurchase |
error | Afficher error.message | Oui, si pendingCompletePurchase |
canceled | Revenir discrètement au paywall | Oui, si pendingCompletePurchase |
Ce qui peut mal tourner avec les achats intégrés
Quatre erreurs courantes font perdre des achats ou entraînent le rejet des applications : l’absence de bouton de restauration, un abonnement au stream qui démarre trop tard, l’absence d’appel à completePurchase, ou une confiance aveugle envers le client.
Absence de bouton de restauration. La directive 3.1.1 d’Apple impose aux applications de proposer un moyen de restaurer tout achat intégré restaurable. Les achats restaurés arrivent sur purchaseStream avec le statut PurchaseStatus.restored : le bouton n’a donc qu’à appeler restorePurchases(), et votre handler existant accorde l’accès. Placez-le sur le paywall et dans les réglages :
TextButton(
onPressed: PurchaseService.instance.restore,
child: const Text('Restore Purchases'),
)
Si une notification push ou un lien marketing ouvre le paywall, gérez le routage avec le Deep Linking dans Flutter afin que le bouton de restauration soit toujours accessible en un seul tap.
Écoute trop tardive. Un achat qui se finalise alors que votre application est fermée, comme une demande Ask to Buy approuvée ou un paiement lent, est délivré sur le stream au prochain lancement de l’application. Si le listener est attaché sur un écran que l’utilisateur n’atteint jamais, cette mise à jour n’est pas traitée. L’utilisateur ne reçoit pas ce pour quoi il a payé et, sur Android, un achat jamais finalisé est remboursé au bout de trois jours.
Absence de finalisation. Si pendingCompletePurchase vaut true pour un achat et que completePurchase n’est jamais appelé, Google Play le rembourse au bout de trois jours et l’App Store laisse la transaction inachevée.
Confiance aveugle envers le client. Ne débloquez pas le contenu sur la seule base du statut reçu par votre application. Envoyez verificationData.serverVerificationData à votre backend et validez-le côté serveur : avec l’App Store Server API sur les plateformes Apple, ou avec la Google Play Developer API sur Android. Avec StoreKit 2, serverVerificationData est une transaction JWS signée. Votre serveur doit vérifier sa signature, rechercher la transaction via l’App Store Server API, ou les deux. La requête de vérification est un appel backend ordinaire. Si votre backend utilise GraphQL, l’article Consuming GraphQL APIs with Flutter explique comment le mettre en place.
La conception du paywall et les SDK de gestion d’abonnements tels que RevenueCat sont des choix distincts qui viennent se greffer sur ce parcours.
Comment tester les achats sans dépenser d’argent réel ?
Apple et Google fournissent chacun un environnement de test pour les achats intégrés. Apple propose des comptes sandbox et un fichier de configuration StoreKit dans Xcode. Google propose des testeurs de licence sur un canal de test Play.
- Testeurs sandbox Apple : créez des comptes sandbox dans App Store Connect et connectez-vous à l’un d’eux sur un appareil. Les achats en sandbox utilisent vos véritables produits App Store Connect sans débiter qui que ce soit.
- Fichier de configuration StoreKit : avec un fichier de configuration StoreKit dans Xcode, vous pouvez définir des produits en local et tester les achats dans le simulateur iOS avant même qu’App Store Connect soit entièrement configuré.
- Testeurs de licence Play : dans la Play Console, ouvrez Paramètres > Test de licence (Settings > License testing) et ajoutez des comptes Google. Les achats de test effectués par des testeurs de licence ne sont pas débités sur leur compte. Le guide de test de la facturation de Google avertit que les testeurs inscrits sur un canal de test qui ne sont pas testeurs de licence paient en argent réel.
Testez une installation neuve ainsi que la restauration sur les deux plateformes avant de soumettre votre application.
Passez à la mise en production
Pour un parcours d’achat intégré Flutter correct : configurez les deux stores, abonnez-vous à purchaseStream avant toute autre chose, vérifiez chaque achat sur votre serveur et finalisez chaque achat pour lequel pendingCompletePurchase est défini. Ensuite, créez un produit dans chaque console, testez-le avec un compte sandbox et un testeur de licence, et assurez-vous qu’il apparaît dans productDetails et non dans notFoundIDs.
FAQ
Comment passer à un abonnement supérieur ou inférieur sur Android avec in_app_purchase ?
Appelez buyNonConsumable avec un GooglePlayPurchaseParam incluant un ChangeSubscriptionParam. Transmettez-lui l'ancien GooglePlayPurchaseDetails de l'utilisateur et, si vous le souhaitez, un ReplacementMode tel que ReplacementMode.withTimeProration. Ces classes proviennent de in_app_purchase_android : importez donc ce package lorsque vous en avez besoin. Sur iOS, cette étape est inutile. Les abonnements d'un même groupe d'abonnements se remplacent déjà mutuellement, et l'App Store gère donc le changement.
Le plugin in_app_purchase fonctionne-t-il avec Flutter Web ?
Non. Le plugin a besoin d'un store sous-jacent, et il ne prend en charge que l'App Store (sur iOS et macOS) et Google Play (sur Android). Un build Flutter Web ne repose sur aucun store : les ventes sur le web nécessitent donc un processeur de paiement web tel que Stripe. La restriction des plateformes concernant Stripe s'applique aux biens numériques vendus dans les applications iOS et Android, et non aux achats effectués sur le web.
Quelle est la différence entre in_app_purchase et purchases_flutter de RevenueCat ?
in_app_purchase gère uniquement la connexion au store. La vérification côté serveur, le stockage des droits d'accès et le suivi de l'état des abonnements sont laissés à votre propre backend. purchases_flutter de RevenueCat encapsule StoreKit et Google Play Billing avec le backend hébergé de RevenueCat, qui ajoute la validation des reçus côté serveur, le suivi du statut des abonnements et des webhooks. Les deux packages passent toujours par la facturation d'Apple et de Google : les stores prélèvent donc la même commission dans les deux cas.
Que se passe-t-il pour les abonnés existants lorsque j'augmente le prix d'un abonnement ?
Cela dépend du store, de la région et de l'ampleur de l'augmentation. Certaines augmentations nécessitent l'accord de l'abonné et, s'il ne l'accepte pas, l'abonnement cesse d'être renouvelé. Les deux stores autorisent également certaines augmentations avec une simple notification. Sur iOS, StoreKit affiche par défaut une fenêtre de consentement au nouveau prix dans votre application, et le README de in_app_purchase explique comment la différer à un moment plus opportun. Sur Android, Google Play informe les abonnés du changement par e-mail et par notification : aucun code Dart n'est donc nécessaire pour le déclencher.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k