dooboolab-community / react-native-iap

In App Purchase module for React Native!
https://react-native-iap.dooboolab.com
MIT License
2.74k stars 633 forks source link

What if requestSubscription succeeds but receipt validation on server fails? #545

Closed jvandenaardweg closed 3 years ago

jvandenaardweg commented 5 years ago

Version of react-native-iap

3.0.0

Version of react-native

0.59.9

Platforms you faced the error (IOS or Android or both?)

iOS

Expected behavior

Would like the subscription purchase to not follow through when receipt validation fails for whatever reason.

Actual behavior

Subscription purchase succeeds without receipt validation on server

Tested environment (Emulator? Real Device?)

Real Device

Steps to reproduce the behavior

I'm using receipt validation on my server to determine a valid receipt and to store that data on my server for reference if the user is still subscribed. This server validation is recommended by Apple, as this does not allow "man in the middle" attacks.

With the new 3.0.0 release (actually, when I started implementing IAP) i'm missing a way to do this in a transaction-like manner. Because, if my receipt validation server is erroring, down, having maintenance etc... and the user buys a subscription, it will succeed to buy it, but it will fail to validate it. Resulting in a not validated purchased subscription, so i cannot give that user access to subscriber features.

The user will be mad because he is being charged, but not giving the right access.

Above is something that did not happen yet, but theoretically possible.

I think we need some kind of transaction, which is already in the Apple SDK's if i read them correctly: https://developer.apple.com/documentation/storekit/skpaymentqueue https://developer.apple.com/documentation/storekit/skpaymentqueue/1506003-finishtransaction https://docs.microsoft.com/nl-nl/xamarin/ios/platform/in-app-purchasing/store-kit-overview-and-retreiving-product-information (nice overview with flowcharts, ignore it's for Xamarin)

But, i don't know that much about the StoreKit API's if this is even possible. But i guess it is possible: https://stackoverflow.com/a/23258538/3194288

The thing I have in mind (proposal):

componentDidMount() {
  this.transactionListenerSubscription = RNIap.transactionListener(({ isFinished, isStopped }) => {
    if (isStopped) return Alert.alert('Failed...')

    return Alert.alert('Success!')
  }
}

handleOnPressUpgrade = () => {
  RNIap.transaction(async (t) => {
    try {
      const { receipt } = await RNIap.requestSubscription('com.app.something.premium')
      await validateReceiptOnServer(receipt)
      t.finish()
    } catch (err) {
      t.stop()
    }
  }
}

Only after t.finish() is called, the purchase should be confirmed. t.stop() should just rollback/cancel all

Below is how I do it now, which could fail when validateSubscriptionReceipt fails:


componentDidMount() {
    this.purchaseUpdateSubscription = RNIap.purchaseUpdatedListener(async (purchase: RNIap.ProductPurchase) => {
      try {
        await this.props.validateSubscriptionReceipt(subscription.id, purchase.transactionReceipt);

        // The validation result is handled in componentDidUpdate
      } catch (err) {
        const errorMessage = (err && err.message) ? err.message : 'An uknown error happened while upgrading.';
        this.showErrorAlert('Upgrade error', errorMessage);
      } finally {
        this.setState({ isLoadingRestorePurchases: false, isLoadingBuySubscription: false });
      }
    });

    this.purchaseErrorSubscription = RNIap.purchaseErrorListener(async (error: RNIap.PurchaseError) => {
      this.showErrorAlert('Oops!', `An error happened. Please contact our support with this information:\n\n ${JSON.stringify(error)}`);
      this.setState({ isLoadingRestorePurchases: false, isLoadingBuySubscription: false });
    });
  }

handleOnPressUpgrade = async () => {
    return this.setState({ isLoadingBuySubscription: true }, async () => {
      try {
        await RNIap.requestSubscription(SUBSCRIPTION_PRODUCT_ID);
      } catch (err) {
        const errorMessage = (err && err.message) ? err.message : 'An uknown error happened while upgrading.';

        return this.setState({ isLoadingBuySubscription: false }, () =>
          this.showErrorAlert('Upgrade error', errorMessage)
        );
      }
    });
  }
hyochan commented 5 years ago

Very considerable issue!. Yes you are right and this could be improved. Also note in mind that you can still use buyProduct in 3.0.+ which will be removed in later releases like 4.0.0 maybe.

Let's try to investigate this issue.

vilius commented 5 years ago

Trying to solve the same issue. Am I right to think that following is currently an alternative:

const purchases = await RNIap.getPurchaseHistory();
purchases.forEach((purchase) => {
  validateSubscriptionReceipt(purchase.transactionReceipt);
});

Server in return will process the purchase or ignore it if it was already consumed.

For some reason when dealing with consumable products in iOS Sandbox both methods RNIap.getPurchaseHistory and RNIap.getAvailablePurchases return empty arrays.

I see that there used to be a method called buyProductWithoutFinishTransaction. I assume this could also help solve this problem.

garfiaslopez commented 4 years ago

I think that this issue is fixed with the implementation of finishTransactionIOS()? Like in the Readme code example? Or this could still be happening?

componentDidMount() {
    this.purchaseUpdateSubscription = purchaseUpdatedListener((purchase: ProductPurchase) => {
      console.log('purchaseUpdatedListener', purchase);
      const receipt = purchase.transactionReceipt;
      if (receipt) {
        yourAPI.deliverOrDownloadFancyInAppPurchase(purchase.transactionReceipt)
        .then((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 untill you do this.
            if (Platform.OS === 'ios') {
              RNIap.finishTransactionIOS(purchase.transactionId);
            } else if (Platform.OS === 'android') {
              // If consumable (can be purchased again)
              RNIap.consumePurchaseAndroid(purchase.purchaseToken);
              // If not consumable
              RNIap.acknowledgePurchaseAndroid(purchase.purchaseToken);
            }
          } else {
            // Retry / conclude the purchase is fraudulent, etc...
          }
        });
      }
    });
vilius commented 4 years ago

@garfiaslopez you are correct, properly disclosed and fixed in this PR https://github.com/dooboolab/react-native-iap/issues/581

matamicen commented 4 years ago

@garfiaslopez Thanks for share, We did the same approach.

Can you help me with the Validation in Android? I couldn't find a nice tutorial to do it, I mean at least I want to do it from POStman in order to check the flow then I can code in any language. I don't know what information to pass to the POST of https://www.googleapis.com/auth/androidpublisher API in order to get the token, it goes on the body? on the header? Do i need to create just a Service Account or and oAuth2 ? Any help will be appreciated. @hyochan

Thanks guys. Matt.

hyochan commented 4 years ago

@matamicen Although this blog post is written in Korean, I think it may help you the idea.

gg-vhong commented 3 years ago

So just some additional info. It doesn't look like consumables are returned on getPurchaseHistory.

getPurchaseHistory calls [[SKPaymentQueue defaultQueue] restoreCompletedTransactions].

Apple's restoreCompletedTransactions documentation (link)

Which suggests that (@vilius) looping over getPurchaseHistory (for consumables) returning empty is as intended.

Apple's finishtransaction documentation [(link)] (https://developer.apple.com/documentation/storekit/skpaymentqueue/1506003-finishtransaction) mentions a few more things:

So it does sound like restoring consumables are kinda left to the individual apps. From as far as 2013, people have used the keychain as a persistent store - which may be more reliable than tracking receipts server side (link).

The most annoying type of API edge case. Low frequency, high customer stress, and a good amount of manual impl cost.

stale[bot] commented 3 years ago

Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs. You may also label this issue as "For Discussion" or "Good first issue" and I will leave it open. Thank you for your contributions.

stale[bot] commented 3 years ago

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please feel free to create a new issue with up-to-date information.