Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/avoid blindly consuming success purchases #1085

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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