Skip to content

Commit

Permalink
Fix/avoid blindly consuming success purchases (#1085)
Browse files Browse the repository at this point in the history
If the purchase is really pending, nothing will happen (error). Otherwise, the Play Store cache will
be force updated

* fix: When possible, properly check that module is defined (instead of throwing non-catchable promise

* fix: Avoid consuming all purchase blindly, but only pending ones
  • Loading branch information
Amaury Liet committed Sep 14, 2020
1 parent f546bc5 commit 6d591e5
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 85 deletions.
2 changes: 1 addition & 1 deletion IapExample/App.js
Expand Up @@ -116,7 +116,7 @@ class Page extends Component {
async componentDidMount(): void {
try {
const result = await RNIap.initConnection();
await RNIap.consumeAllItemsAndroid();
await RNIap.flushFailedPurchasesCachedAsPendingAndroid();
console.log('result', result);
} catch (err) {
console.warn(err.code, err.message);
Expand Down
79 changes: 45 additions & 34 deletions README.md
Expand Up @@ -124,7 +124,8 @@ _*deprecated_<br>~~`buySubscription(sku: string)`~~<ul><li>sku: subscription ID/
`getPendingPurchasesIOS()` | `Promise<ProductPurchase[]>` | **IOS only**<br>Gets all the transactions which are pending to be finished.
`validateReceiptIos(body: Record<string, unknown>, devMode: boolean)`<ul><li>body: receiptBody</li><li>devMode: isTest</li></ul> | `Object\|boolean` | **iOS only**<br>Validate receipt.
`endConnection()` | `Promise<void>` | End billing connection.
`consumeAllItemsAndroid()` | `Promise<void>` | **Android only**<br>Consume all items so they are able to buy again.
`consumeAllItemsAndroid()` | `Promise<void>` | **Android only**<br>Consume all items so they are able to buy again. ⚠️ Use in dev only (as you should deliver the purchased feature BEFORE consuming it)
`flushFailedPurchasesCachedAsPendingAndroid()` | `Promise<void>` | **Android only**<br>Consume all 'ghost' purchases (that is, pending payment that already failed but is still marked as pending in Play Store cache)
`consumePurchaseAndroid(token: string, payload?: string)`<ul><li>token: purchase token</li><li>payload: developerPayload</li></ul> | `void` | **Android only**<br>Finish a purchase. All purchases should be finished once you have delivered the purchased items. E.g. by recording the purchase in your database or on your server.
`acknowledgePurchaseAndroid(token: string, payload?: string)`<ul><li>token: purchase token</li><li>payload: developerPayload</li></ul> | `Promise<PurchaseResult>` | **Android only**<br>Acknowledge a product. Like above for non-consumables. Use `finishTransaction` instead for both platforms since version 4.1.0 or later.
`consumePurchaseAndroid(token: string, payload?: string)`<ul><li>token: purchase token</li><li>payload: developerPayload</li></ul> | `Promise<PurchaseResult>` | **Android only**<br>Consume a product. Like above for consumables. Use `finishTransaction` instead for both platforms since version 4.1.0 or later.
Expand Down Expand Up @@ -347,42 +348,52 @@ class RootComponent extends Component<*> {
purchaseErrorSubscription = null

componentDidMount() {
this.purchaseUpdateSubscription = purchaseUpdatedListener((purchase: InAppPurchase | SubscriptionPurchase | ProductPurchase ) => {
console.log('purchaseUpdatedListener', purchase);
const receipt = purchase.transactionReceipt;
if (receipt) {
yourAPI.deliverOrDownloadFancyInAppPurchase(purchase.transactionReceipt)
.then( async (deliveryResult) => {
if (isSuccess(deliveryResult)) {
// Tell the store that you have delivered what has been paid for.
// Failure to do this will result in the purchase being refunded on Android and
// the purchase event will reappear on every relaunch of the app until you succeed
// in doing the below. It will also be impossible for the user to purchase consumables
// again until you do this.
if (Platform.OS === 'ios') {
await RNIap.finishTransactionIOS(purchase.transactionId);
} else if (Platform.OS === 'android') {
// If consumable (can be purchased again)
await RNIap.consumePurchaseAndroid(purchase.purchaseToken);
// If not consumable
await RNIap.acknowledgePurchaseAndroid(purchase.purchaseToken);
}

// From react-native-iap@4.1.0 you can simplify above `method`. Try to wrap the statement with `try` and `catch` to also grab the `error` message.
// If consumable (can be purchased again)
await RNIap.finishTransaction(purchase, true);
// If not consumable
await RNIap.finishTransaction(purchase, false);
} else {
// Retry / conclude the purchase is fraudulent, etc...
Iap.initConnection().then(() => {
// we make sure that "ghost" pending payment are removed
// (ghost = failed pending payment that are still marked as pending in Google's native Vending module cache)
Iap.flushFailedPurchasesCachedAsPendingAndroid().catch(() => {
// exception can happen here if:
// - there are pending purchases that are still pending (we can't consume a pending purchase)
// in any case, you might not want to do anything special with the error
}).then(() => {
this.purchaseUpdateSubscription = purchaseUpdatedListener((purchase: InAppPurchase | SubscriptionPurchase | ProductPurchase ) => {
console.log('purchaseUpdatedListener', purchase);
const receipt = purchase.transactionReceipt;
if (receipt) {
yourAPI.deliverOrDownloadFancyInAppPurchase(purchase.transactionReceipt)
.then( async (deliveryResult) => {
if (isSuccess(deliveryResult)) {
// Tell the store that you have delivered what has been paid for.
// Failure to do this will result in the purchase being refunded on Android and
// the purchase event will reappear on every relaunch of the app until you succeed
// in doing the below. It will also be impossible for the user to purchase consumables
// again until you do this.
if (Platform.OS === 'ios') {
await RNIap.finishTransactionIOS(purchase.transactionId);
} else if (Platform.OS === 'android') {
// If consumable (can be purchased again)
await RNIap.consumePurchaseAndroid(purchase.purchaseToken);
// If not consumable
await RNIap.acknowledgePurchaseAndroid(purchase.purchaseToken);
}

// From react-native-iap@4.1.0 you can simplify above `method`. Try to wrap the statement with `try` and `catch` to also grab the `error` message.
// If consumable (can be purchased again)
await RNIap.finishTransaction(purchase, true);
// If not consumable
await RNIap.finishTransaction(purchase, false);
} else {
// Retry / conclude the purchase is fraudulent, etc...
}
});
}
});
}
});

this.purchaseErrorSubscription = purchaseErrorListener((error: PurchaseError) => {
console.warn('purchaseErrorListener', error);
});
this.purchaseErrorSubscription = purchaseErrorListener((error: PurchaseError) => {
console.warn('purchaseErrorListener', error);
});
})
})
}

componentWillUnmount() {
Expand Down
85 changes: 64 additions & 21 deletions android/src/main/java/com/dooboolab/RNIap/RNIapModule.java
Expand Up @@ -20,6 +20,7 @@

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;

Expand Down Expand Up @@ -177,6 +178,35 @@ public void endConnection(final Promise promise) {
}
}

private void consumeItems(final List<Purchase> purchases, final Promise promise) {
consumeItems(purchases, promise, BillingClient.BillingResponseCode.OK);
}

private void consumeItems(final List<Purchase> purchases, final Promise promise, final int expectedResponseCode) {
for (Purchase purchase : purchases) {
final ConsumeParams consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.setDeveloperPayload(purchase.getDeveloperPayload())
.build();

final ConsumeResponseListener listener = new ConsumeResponseListener() {
@Override
public void onConsumeResponse(BillingResult billingResult, String outToken) {
if (billingResult.getResponseCode() != expectedResponseCode) {
DoobooUtils.getInstance().rejectPromiseWithBillingError(promise, billingResult.getResponseCode());
return;
}
try {
promise.resolve(true);
} catch (ObjectAlreadyConsumedException oce) {
promise.reject(oce.getMessage());
}
}
};
billingClient.consumeAsync(consumeParams, listener);
}
}

@ReactMethod
public void refreshItems(final Promise promise) {
// Purchase.PurchasesResult purchasesResult = billingClient.queryPurchases(BillingClient.SkuType.INAPP);
Expand All @@ -196,29 +226,42 @@ public void run() {
return;
}

consumeItems(purchases, promise);
}
});
}

@ReactMethod
public void flushFailedPurchasesCachedAsPending(final Promise promise) {
ensureConnection(promise, new Runnable() {
@Override
public void run() {
final WritableNativeArray array = new WritableNativeArray();
Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.INAPP);
if (result == null) {
// No results for query
promise.resolve(false);
return;
}
final List<Purchase> purchases = result.getPurchasesList();
if (purchases == null) {
// No purchases found
promise.resolve(false);
return;
}
final List<Purchase> pendingPurchases = Collections.EMPTY_LIST;
for (Purchase purchase : purchases) {
final ConsumeParams consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.setDeveloperPayload(purchase.getDeveloperPayload())
.build();

final ConsumeResponseListener listener = new ConsumeResponseListener() {
@Override
public void onConsumeResponse(BillingResult billingResult, String outToken) {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
DoobooUtils.getInstance().rejectPromiseWithBillingError(promise, billingResult.getResponseCode());
return;
}
array.pushString(outToken);
try {
promise.resolve(true);
} catch (ObjectAlreadyConsumedException oce) {
promise.reject(oce.getMessage());
}
}
};
billingClient.consumeAsync(consumeParams, listener);
// we only want to try to consume PENDING items, in order to force cache-refresh for them
if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
pendingPurchases.add(purchase);
}
}
if (pendingPurchases.size() == 0) {
promise.resolve(false);
return;
}

consumeItems(pendingPurchases, promise, BillingClient.BillingResponseCode.ITEM_NOT_OWNED);
}
});
}
Expand Down

0 comments on commit 6d591e5

Please sign in to comment.