Las compras dentro de la aplicación de Android, parte 2: procesamiento de las compras con la Biblioteca de Facturación Google Play.
Updated: marzo 20, 2023
En el artículo anterior, creamos una clase contenedora para trabajar con la Biblioteca de Facturación:
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
}
}
Pasemos a la implementación de la compra y mejoremos nuestra clase.
Diseño de una pantalla de suscripción
Cualquier aplicación que incluya compras dentro de la aplicación tiene una pantalla de muro de pago (Paywall). Hay políticas de Google que definen el mínimo de elementos y textos instructivos que deben estar presentes en dichas pantallas. He aquí un resumen. En la pantalla de muro de pago, debes ser explícito sobre las condiciones de suscripción, el coste y la duración, así como especificar si la suscripción es necesaria para utilizar la aplicación. Además, debes evitar obligar a tus usuarios a realizar cualquier acción adicional para revisar las condiciones.
Aquí utilizaremos como ejemplo una pantalla de muro de pago simplificada:
En la pantalla de muro de pago disponemos de los siguientes elementos:
- Un encabezado.
- Botones configurados para iniciar el proceso de compra. Estos botones guían al usuario sobre los detalles generales de las opciones de suscripción, como el título y el coste, que se muestra en la moneda local.
- Texto instructivo.
- Un botón para restaurar la compra realizada anteriormente. Este elemento es imprescindible para cualquier aplicación que incluya suscripciones o compras no consumibles.
Cómo adaptar el código para mostrar los detalles de los productos
En nuestro ejemplo hay cuatro productos:
- Dos suscripciones autorrenovables (auto-renewable subscription) («premium_sub_month» y «premium_sub_year»);
- Un producto que no se puede comprar dos veces, o un producto no consumible («unlock_feature»);
- Un producto que se puede comprar varias veces, o un producto consumible («coin_pack_large»).
Para simplificar el ejemplo, utilizaremos una Actividad que inyectaremos con el BillingClientWrapper creado en el artículo anterior, así como un diseño con un número fijo de botones de compra.
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
}
}
Por comodidad, añadiremos un mapa en el que la clave es el sku del producto, y el valor es el botón correspondiente en la pantalla.
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,
)
}
Declaremos un método para mostrar los productos en la interfaz de usuario (UI). Se basará en la lógica introducida en nuestro tutorial anterior.
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 es una cadena ya formateada que especifica la moneda local de la cuenta. No necesita ningún formato adicional. Todas las demás propiedades del objeto de clase SkuDetails también están totalmente localizadas.
Inicio del proceso de compra
Para procesar la compra, tenemos que invocar el método launchBillingFlow() desde el hilo principal de la aplicación.
Añadiremos un método purchase() al BillingClientWrapper para hacerlo.
fun purchase(activity: Activity, product: SkuDetails) {
onConnected {
activity.runOnUiThread {
billingClient.launchBillingFlow(
activity,
BillingFlowParams.newBuilder().setSkuDetails(product).build()
)
}
}
}
El método launchBillingFlow() no tiene devolución de llamada. La respuesta volverá al método onPurchasesUpdated(). ¿Recuerdas que lo declaramos en el artículo anterior y lo guardamos para más adelante? Pues ahora lo necesitaremos.
El método onPurchasesUpdated() se llama siempre que hay algún resultado de la interacción del usuario con el diálogo de compra. Puede tratarse de una compra realizada con éxito, o de una cancelación de la compra causada por el usuario al cerrar el diálogo, en cuyo caso obtendremos el código BillingResponseCode.USER_CANCELED. Alternativamente, puede ser cualquiera de los otros mensajes de error posibles.
De forma similar a la interfaz OnQueryProductsListener del artículo anterior, declararemos una interfaz OnPurchaseListener en la clase BillingClientWrapper. A través de esa interfaz, recibiremos la compra (un objeto de la clase Purchase) o un mensaje de error declarado por nosotros en la guía anterior. Más adelante trataremos el caso en el que el objeto de la clase Purchase puede ser nulo aunque la compra se haya realizado con éxito.
interface OnPurchaseListener {
fun onPurchaseSuccess(purchase: Purchase?)
fun onPurchaseFailure(error: Error)
}
var onPurchaseListener: OnPurchaseListener? = null
A continuación, lo implementaremos en la 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
}
})
}
}
2024 subscription benchmarks and insights
Get your free copy of our latest subscription report to stay ahead in 2024.
Añádele algo de lógica a 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 purchaseList no está vacía, primero tendremos que elegir los artículos cuyo PurchaseState sea igual a PurchaseState.PURCHASED, ya que también hay compras pendientes. Si ese es el caso, el flujo de usuario termina aquí. Según los documentos, a continuación debemos verificar la compra en nuestro servidor. Trataremos este tema en los siguientes artículos de esta serie. Una vez completada la verificación en el servidor, debes entregar el contenido y avisar a Google. Sin esto último, la compra se reembolsará automáticamente en tres días. Es bastante interesante que esta política sea exclusiva de Google Play: iOS no establece nada similar. Dispones de dos formas de reconocer la entrega de tu contenido al usuario:
- A través de acknowledgePurchase() en el lado del cliente;
- A través de Product.Purchases.Acknowledge/Purchases.Subscriptions.Acknowledge en el lado del backend.
Si se trata de un producto consumible, tenemos que invocar el método consumeAsync() en su lugar. Esto reconoce la compra bajo el capó, a la vez que permite volver a comprar el producto. Esto sólo puede hacerse con la Biblioteca de Facturación. Por alguna razón, la API para desarrolladores de Google Play no ofrece ninguna forma de hacer esto en el lado del backend. Es bastante curioso que, a diferencia de Google Play, tanto App Store como AppGallery configuran el producto como consumible mediante App Store Connect y AppGallery Connect, respectivamente. Aunque estos productos de AppGallery también deberían consumirse de forma explícita.
Escribamos los métodos para reconocer y consumir, así como dos versiones del método processPurchase() para tener en cuenta si ejecutamos o no nuestro propio backend.
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)
}
}
}
Sin verificación del servidor:
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()
}
}
}
}
}
Con verificación del servidor:
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
}
}
}
}
En los siguientes artículos, trataremos más detalladamente la verificación del servidor para las compras.
Por supuesto, también podríamos implementar el reconocimiento del segundo ejemplo en el lado del cliente. Sin embargo, si puedes hacer algo en el backend, deberías hacerlo. Si el proceso de confirmación o de consumo arroja algún error, no se puede ignorar. En caso de que no se ejecute ninguna de ellas en los 3 días siguientes a que la compra reciba el estado PurchaseState.PURCHASED, se cancelará y se reembolsará. Así que si es imposible hacerlo en el backend y sigues recibiendo el error después de varios intentos, hay una solución fiable. Deberás obtener las compras actuales del usuario a través de algún método del ciclo de vida, como onStart() o onResume(), y seguir intentándolo, con la esperanza de que el usuario abra la aplicación en un plazo de 3 días mientras esté conectado a Internet. 🙂
Por lo tanto, la versión actual de la clase BillingClientWrapper tendrá el siguiente aspecto:
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)
}
}
}
}
Quizás quieras preguntarte por qué los botones están activos para todos los productos, sin importar si el usuario ya los compró o no. O puede que tengas algunas preguntas sobre lo que ocurre si compras ambas suscripciones. ¿La primera suscripción sustituirá a la segunda, o coexistirán? Consulta los próximos artículos para obtener todas las respuestas 🙂