BlogRight arrowTutorialRight ArrowAchats intégrés sous Android, 2e partie : traitement des achats avec la service de facturation de Google Play
BlogRight arrowTutorialRight ArrowAchats intégrés sous Android, 2e partie : traitement des achats avec la service de facturation de Google Play

Achats intégrés sous Android, 2e partie : traitement des achats avec la service de facturation de Google Play

Achats intégrés sous Android, 2e partie : traitement des achats avec la service de facturation de Google Play
Listen to the episode
Achats intégrés sous Android, 2e partie : traitement des achats avec la service de facturation de Google Play

Dans l'article précédent, nous avons créé une classe enveloppante pour travailler avec le service de facturation :

import android.content.Context
import com.android.billingclient.api.*
class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }
   class Error(val responseCode: Int, val debugMessage: String)
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp")
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }
   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }
   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }
   override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) {
       // here come callbacks about new purchases
   }
}

Passons à l'implémentation de l'achat et améliorons notre classe.

⭐️ Download our guide on in-app techniques which will make in-app purchases in your app perfect

Conception d'un écran d'abonnement

Toute application qui propose des achats intégrés a un écran de paiement. Il existe des Règles de confidentialité et Conditions d'utilisation de Google qui définissent le strict minimum d'éléments et de textes pédagogiques qui doivent être présents dans ces écrans. Voici un résumé. Dans l'écran du paywall, vous devez être explicite sur les conditions, le coût et la durée de votre abonnement, et préciser si l'abonnement est nécessaire pour utiliser l'application. Vous devez également éviter de forcer vos utilisateurs à effectuer une action supplémentaire pour examiner les conditions. 

Ici, nous utiliserons un écran simplifié de paywall comme exemple :

Échantillon pour Android de Paywall

Nous avons les éléments suivants sur l'écran du paywall :

  • Une en-tête
  • Des boutons permettant de lancer le processus d'achat. Ces boutons guident l'utilisateur vers les détails généraux des options d'abonnement, tels que le titre et le coût, qui est affiché en devise locale.
  • Des instructions sous forme de texte. 
  • Un bouton pour restaurer l'achat précédemment effectué. Cet élément est indispensable pour toute application qui propose des abonnements ou des achats non consommables.

Modification du code pour afficher les détails du produit

Il y a quatre produits dans notre exemple :

  • Deux abonnements auto-renouvelables ("premium_sub_month_" et " premium_sub_year") ;
  • Un produit qui ne peut pas être acheté deux fois, ou un produit non consommable ("fonction de _déverrouillage") ;
  • Un produit qui peut être acheté plusieurs fois, ou un produit consommable ("coin_pack_large") ;

Pour simplifier l'exemple, nous utiliserons une activité que nous injecterons avec le BillingClientWrapper créé dans l'article précédent, ainsi qu'une mise en page avec un nombre fixe de boutons d'achat.

class PaywallActivity: BaseActivity() {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       displayProducts() //to be declared below
   }
}

Pour plus de commodité, nous ajouterons une carte où la clé est la référence sku du produit et la valeur est le bouton correspondant sur l'écran.

private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
   mapOf(
       "premium_sub_month" to monthlySubButton,
       "premium_sub_year" to yearlySubButton,
       "coin_pack_large" to coinPackLargeButton,
       "unlock_feature" to unlockFeatureButton,
   )
}

Déclarons une méthode pour afficher les produits dans l'interface utilisateur. Il sera basé sur la logique introduite dans notre précédent tutoriel.

private fun displayProducts() {
   billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
       override fun onSuccess(products: List<SkuDetails>>) {
           products.forEach { product ->
               purchaseButtonsMap[product.sku]?.apply {
                   text = "${product.description} for ${product.price}"
                   setOnClickListener {
                       billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below
                   }
               }
           }
       }

       override fun onFailure(error: BillingClientWrapper.Error) {
           //handle error
       }
   })
}

product.price est une chaîne déjà formatée qui spécifie la devise locale du compte. Elle ne nécessite pas de mise en forme supplémentaire. Toutes les autres propriétés de l'objet de la classe SkuDetails sont également entièrement localisées.

Démarrer le processus d'achat

Pour traiter l'achat, nous devons invoquer la méthode launchBillingFlow() depuis le fil d'exécution (thread) principal de l'application.

Pour ce faire, nous allons ajouter un mode d'achat() au BillingClientWrapper. 

fun purchase(activity: Activity, product: SkuDetails) {
   onConnected {
       activity.runOnUiThread {
           billingClient.launchBillingFlow(
               activity,
               BillingFlowParams.newBuilder().setSkuDetails(product).build()
           )
       }
   }
}

La méthode launchBillingFlow() n'a pas de fonction de rappel. La réponse sera renvoyée à la méthode onPurchasesUpdated(). Vous vous souvenez que nous l'avons déclaré dans l'article précédent  et que nous l'avons gardé pour plus tard ? Eh bien, nous en aurons besoin maintenant.

La méthode onPurchasesUpdated() est appelée chaque fois qu'un résultat est obtenu à la suite de l'interaction de l'utilisateur avec la boîte de dialogue d'achat. Il peut s'agir d'un achat réussi, ou d'une annulation d'achat causée par la fermeture de la boîte de dialogue par l'utilisateur, auquel cas nous obtiendrons le code BillingResponseCode.USER_CANCELED. Il peut également s'agir de l'un des autres messages d'erreur possibles.

De manière similaire à l'interface OnQueryProductsListener de l'article précédent, nous allons déclarer une interface OnPurchaseListener dans la classe BillingClientWrapper. Via cette interface, nous recevrons soit l'achat (un objet de la classe Purchase), soit un message d'erreur que nous avons déclaré dans le guide précédent. Dans le prochain article, nous aborderons le cas où l'objet de la classe Achat peut être nul même si l'achat a été effectué avec succès.

interface OnPurchaseListener {
   fun onPurchaseSuccess(purchase: Purchase?)
   fun onPurchaseFailure(error: Error)
}

var onPurchaseListener: OnPurchaseListener? = null

Ensuite, nous allons l'implémenter dans  PaywallActivity :

class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
       mapOf(
           "premium_sub_month" to monthlySubButton,
           "premium_sub_year" to yearlySubButton,
           "coin_pack_large" to coinPackLargeButton,
           "unlock_feature" to unlockFeatureButton,
       )
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       billingClientWrapper.onPurchaseListener = this

       displayProducts()
   }

   override fun onPurchaseSuccess(purchase: Purchase?) {
       //handle successful purchase
   }

   override fun onPurchaseFailure(error: BillingClientWrapper.Error) {
       //handle error or user cancelation
   }

   private fun displayProducts() {
       billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
           override fun onSuccess(products: List<SkuDetails>) {
               products.forEach { product ->
                   purchaseButtonsMap[product.sku]?.apply {
                       text = "${product.description} for ${product.price}"
                       setOnClickListener {
                           billingClientWrapper.purchase(this@PaywallActivity, product)
                       }
                   }
               }
           }

           override fun onFailure(error: BillingClientWrapper.Error) {
               //handle error
           }
       })
   }
}

Ajoutons un peu de logique à onPurchaseUpdated() :

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchaseList: MutableList<Purchase>?
) {
   when (billingResult.responseCode) {
       BillingClient.BillingResponseCode.OK -> {
           if (purchaseList == null) {
               //to be discussed in the next article
               onPurchaseListener?.onPurchaseSuccess(null)
               return
           }

           purchaseList.forEach(::processPurchase) //to be declared below
       }
       else -> {
           //error occured or user canceled
           onPurchaseListener?.onPurchaseFailure(
               BillingClientWrapper.Error(
                   billingResult.responseCode,
                   billingResult.debugMessage
               )
           )
       }
   }
}

Si la liste d'achats n'est pas vide, nous devrons d'abord sélectionner les articles dont l'état d'achat est égal à PurchaseState.PURCHASED, car il y a aussi des achats en en attente. Si c'est le cas, le flux d'utilisateurs se termine ici. Selon les documents, nous devons ensuite vérifier l'achat sur notre serveur. Nous en parlerons dans les articles qui suivront dans cette série. Une fois la vérification du serveur terminée, vous devez livrer le contenu et le faire savoir à Google. Sans ce dernier, l'achat sera automatiquement remboursé dans les trois jours. Il est intéressant de noter que cette politique est propre à Google Play - iOS n'en impose aucune. Vous avez deux façons d'accuser réception de votre contenu à l'utilisateur :

S'il s'agit d'un produit consommable, nous devons invoquer la méthode consumeAsync() à la place. Il reconnaît l'achat sous le capot, tout en rendant possible un nouvel achat du produit. Cela ne peut se faire qu'avec le Service de Facturation. Pour une raison quelconque, le Google Play Developer API n'offre aucun moyen de le faire du côté du back-end. Il est assez curieux que, contrairement à Google Play, l'App Store et l'AppGallery configurent le produit comme consommable via App Store Connect et AppGallery Connect, respectivement. Cependant, ces produits de la galerie d'applications doivent être consommés de manière explicite.

Start for free

Convenient in-app purchases infrastructure.

Adapty SDK has it all:
— server-side purchase validation,
— all side cases covered,
— simple implementation.

Start for free

Écrivons les méthodes de reconnaissance et de consommation, ainsi que deux versions de la méthode processPurchase() pour tenir compte du fait que nous exécutons ou non notre propre back-end.

private fun acknowledgePurchase(
   purchase: Purchase,
   callback: AcknowledgePurchaseResponseListener
) {
   onConnected {
       billingClient.acknowledgePurchase(
           AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
               .build(),
           callback::onAcknowledgePurchaseResponse
       )
   }
}

private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
   onConnected {
       billingClient.consumeAsync(
           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
       ) { billingResult, purchaseToken ->
           callback.onConsumeResponse(billingResult, purchaseToken)
       }
   }
}

Sans vérification du serveur :

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       onPurchaseListener?.onPurchaseSuccess(purchase)

       if (purchase.skus.firstOrNull() == "coin_pack_large") {
           //consuming our only consumable product
           consumePurchase(purchase) { billingResult, purchaseToken ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to consume again in onResume()
               }
           }
       } else if (!purchase.isAcknowledged) {
           acknowledgePurchase(purchase) { billingResult ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to acknowledge again in onResume()
               }
           }
       }
   }
}

Avec vérification du serveur :

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       api.verifyPurchase(purchase.purchaseToken) { error ->
           if (error != null) {
               onPurchaseListener?.onPurchaseSuccess(purchase)

               if (purchase.skus.firstOrNull() == "coin_pack_large") {
                   //consuming our only consumable product
                   billingClient.consumeAsync(
                       ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                           .build()
                   ) { billingResult, purchaseToken ->
                       if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                           //implement retry logic or try to consume again in onResume()
                       }
                   }
               }
           } else {
               //handle verification error
           }
       }
   }
}

Dans les articles suivants, nous aborderons plus en détail la vérification des serveurs pour les achats.

Bien entendu, nous pourrions également mettre en œuvre l'accusé de réception du deuxième exemple du côté client. Cependant, si vous pouvez faire quelque chose en arrière-plan, alors vous devriez le faire. Si l'accusé de réception ou le processus de consommation génère des erreurs, celles-ci ne peuvent être ignorées. Si aucune de ces mesures n'est exécutée dans les 3 jours suivant l'obtention du statut PurchaseState.PURCHASED, l'achat sera annulé et remboursé. Ainsi, s'il est impossible de le faire en arrière-plan et que vous continuez à obtenir l'erreur après un certain nombre de tentatives, il existe une solution de rechange fiable. Vous devrez obtenir les achats actuels de l'utilisateur via une méthode de cycle de vie, telle que onStart() ou onResume(), et continuer à essayer, en espérant que l'utilisateur ouvrira l'application dans les 3 jours tout en étant connecté à Internet. :)

Par conséquent, la version actuelle de la classe BillingClientWrapper ressemblera à ceci :

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.*

class BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener {

   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }

   interface OnPurchaseListener {
       fun onPurchaseSuccess(purchase: Purchase?)
       fun onPurchaseFailure(error: Error)
   }

   var onPurchaseListener: OnPurchaseListener? = null

   class Error(val responseCode: Int, val debugMessage: String)

   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()

   fun purchase(activity: Activity, product: SkuDetails) {
       onConnected {
           activity.runOnUiThread {
               billingClient.launchBillingFlow(
                   activity,
                   BillingFlowParams.newBuilder().setSkuDetails(product).build()
               )
           }
       }
   }

   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature")

       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }

   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }

   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }

           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }

   override fun onPurchasesUpdated(
       billingResult: BillingResult,
       purchaseList: MutableList<Purchase>?
   ) {
       when (billingResult.responseCode) {
           BillingClient.BillingResponseCode.OK -> {
               if (purchaseList == null) {
                   //to be discussed in the next article
                   onPurchaseListener?.onPurchaseSuccess(null)
                   return
               }

               purchaseList.forEach(::processPurchase)
           }
           else -> {
               //error occured or user canceled
               onPurchaseListener?.onPurchaseFailure(
                   BillingClientWrapper.Error(
                       billingResult.responseCode,
                       billingResult.debugMessage
                   )
               )
           }
       }
   }

   private fun processPurchase(purchase: Purchase) {
       if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
           api.verifyPurchase(purchase.purchaseToken) { error ->
               if (error != null) {
                   onPurchaseListener?.onPurchaseSuccess(purchase)

                   if (purchase.skus.firstOrNull() == "coin_pack_large") {
                       //consuming our only consumable product
                       billingClient.consumeAsync(
                           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                               .build()
                       ) { billingResult, purchaseToken ->
                           if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                               //implement retry logic or try to consume again in onResume()
                           }
                       }
                   }
               } else {
                   //handle verification error
               }
           }
       }
   }

   private fun acknowledgePurchase(
       purchase: Purchase,
       callback: AcknowledgePurchaseResponseListener
   ) {
       onConnected {
           billingClient.acknowledgePurchase(
               AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                   .build(),
               callback::onAcknowledgePurchaseResponse
           )
       }
   }

   private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
       onConnected {
           billingClient.consumeAsync(
               ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
           ) { billingResult, purchaseToken ->
               callback.onConsumeResponse(billingResult, purchaseToken)
           }
       }
   }
}

Vous pouvez vous demander pourquoi les boutons sont actifs pour tous les produits, que l'utilisateur les ait déjà achetés ou non. Ou bien, vous avez peut-être des questions sur ce qui se passera si vous achetez les deux abonnements. Le premier abonnement remplacera-t-il le second, ou coexisteront-ils ? Consulter les articles à venir pour toutes les réponses :)

Further reading

Why Firebase just doesn’t work for mobile app paywall A/B testing (+ a better alternative)
Why Firebase just doesn’t work for mobile app paywall A/B testing (+ a better alternative)
November 17, 2022
9 min read
Is the App Store revenue cut too high?
Is the App Store revenue cut too high?
June 25, 2020
5 min read
Development, analytics, attribution. Which services to use in 2021?
Development, analytics, attribution. Which services to use in 2021?
April 9, 2021
12 min read