Android in-app billing - How to handle refunds
Asked Answered
O

2

22

I am trying to implement in-app billing in my app, everything works great except for when it comes to refunds. I have been banging my head against this for the past few days and it seems unbelievable that there is no way to know, form the app side, if a user requested a refund. I would like to be able to revoke access to a one-time managed product (remove ads) after the user has been refunded. I am not using a backend so I am relying on Google Play APIs.

What I have tried is querying the Google Play APIs with queryPurchaseHistoryAsync which returns a list of recent purchases made by the user. This does not seem to work as the purchases are still there after asking for a refund (been waiting for one day before writing this).

Here's what I did:

  1. Install the app on a real device
  2. Buy the in-app content
  3. Verify that app unlocks the content
  4. Go to my Google Play order history and ask for refund for the in-app product
  5. 10 minutes later the transaction got refunded (without me as a developer being involved at all)
  6. App still provides the paid content
  7. Cleaning Play Store App data & cache
  8. App still provides the paid content

So any user can buy my in-app product and then immediately go to his Google Play page and ask for a refund? Is it me missing something obvious or this API is a nightmare?

The PurchaseState enum is

  public @interface PurchaseState {
    // Purchase with unknown state.
    int UNSPECIFIED_STATE = 0;
    // Purchase is completed.
    int PURCHASED = 1;
    // Purchase is waiting for payment completion.
    int PENDING = 2;
  }

I do not see anything related to refunded here, This seems to me a pretty normal use case, so I'm still thinking that I am missing some key piece of information, how do I do this?

Thanks for any help

Odalisque answered 11/5, 2020 at 16:28 Comment(4)
I have not tried it myself but when I looked for that I found these resources: #42404390 developers.google.com/android-publisher/voided-purchases (I have not found the api in the sdk). But I am not sure if that works great without a backend server.Ritualist
Same question for me, Do you have found a solution? Thank youIncrocci
Yes and no, good news is it's possible, bad news is unnecessarily complicated full Google style. I had to create a firebase cloud function to which I pass the purchase token for verification. The function calls Google Play apis to get the actual status of the purchase. You can do all of this with the free plan from Firebase. This actually works as intended. See medium.com/@msasikanth/…Odalisque
Same problem here and no solution till now :(Lancer
Y
12

Problem

Any made purchase will still be recorded even when a user makes a refund, and actually gets the refund. The same is true when you (the developer/app owner) issue a refund/revoke request for the in-app product.

The only difference will be the purchase's "purchaseState". The problem here with Google's Billing Library is that they mask this "purchaseState" value in the purchase.getPurchaseState() call to either PENDNG or PURCHASED state. See in the decompiled code:

public int getPurchaseState() {
    switch(this.zzc.optInt("purchaseState", 1)) {
    case 4:
        return 2; // PENDING
    default:
        return 1; // PURCHASED
    }
}

When you have an in-app product with a refunded state, its state is rather UNSPECIFIED_STATE, which is masked to PURCHASED as in the code above.

Simple Solution

To get over this, simply just ignore the library's purchase.getPurchaseState() method, and instead use your own unmasked custom:

int getUnmaskedPurchaseState(Purchase purchase) {
    int purchaseState = purchase.getPurchaseState();
    try {
        purchaseState = new JSONObject(purchase.getOriginalJson()).optInt("purchaseState", 0);
    } catch (JSONException e) {
        e.printStackTrace();
    }

    return purchaseState;
}

Hard Solution

It seems Google has just done that masked to push you to do this solution. Use the Voided Purchases API to provide a list of orders that are associated with purchases that a user has voided. According to their documentation...

A purchase can be voided in the following ways:

  • The user requests a refund for their order. The user cancels their order.
  • An order is charged back.
  • Developer cancels or refunds order. Note: only revoked orders will be shown in the Voided Purchases API.
  • If developer refunds without setting the revoke option, orders will not show up in the API.
  • Google cancels or refunds order.

However, this solution is not currently available as a Java android library, and you will have instead to make your server requests, and to use Google Play Developer APIs.

Yeseniayeshiva answered 17/8, 2020 at 13:11 Comment(5)
What I ended up using is similar to the voided purchase apis you mentionedOdalisque
Can't remember why but there was something about voided purchases that didn't quite work. However the only difference is that I query a specific purchase instead, using the purchase token. I can confirm that this works.Odalisque
why in the world is it like thisCasseycassi
partially answering my own question; it seems that when a purchase is in this weird state of being refunded but still showing as purchased; the user cannot purchase it again. Launching a billing flow results in "already owned". Maybe it's an anomaly with test card purchases?Casseycassi
@rosghub: Did you find an answer to that "user can not purchase it again" question? I mean, I can deal with the refund by checking if the purchaseState is 0. But the user is then never be able to purchase the product again...Lindley
P
0

For handling refund, you should query purchases, ideally onResume() of Activity:

fun queryPurchases() {
    MainScope().launch {
        Log.d(TAG, "queryPurchases: Called")
        val params = QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.INAPP)
            .build()
        val queryPurchasesResult = billingClient.queryPurchasesAsync(params)

        if (queryPurchasesResult.billingResult.responseCode == BillingResponseCode.OK) {

            val hasPremiumPurchase = queryPurchasesResult.purchasesList.any { purchase ->
                purchase.purchaseState == PurchaseState.PURCHASED && purchase.products.contains(Premium_Product_ID)
            }

            // If any premium purchase is found, invoke the restore callback
            if (hasPremiumPurchase) {
                Log.d(TAG, "queryPurchases: SHOULD RESTORE PURCHASE")
                onRestorePurchase?.invoke()
                return@launch
            }
            if (queryPurchasesResult.purchasesList.isEmpty()) {
                Log.d(TAG, "queryPurchases: No premium purchase found. UNSET PREMIUM STATUS")
                unsetPremiumStatus?.invoke()
            }
        }
    }
}

And when purchasesList is empty or does not contain purchase in state of PurchaseState.PURCHASED with Premium_Product_ID you should unset premium status

Purify answered 27/12, 2023 at 16:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.