👻 A dead simple In-App Purchase library for React Native
$ yarn add @class101/react-native-in-app-purchase
# RN >= 0.60
cd ios && pod install
# RN < 0.60
react-native link @class101/react-native-in-app-purchase
Add BILLING
permission to the AndroidManifest.xml
.
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.rninapppurchasesample">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.vending.BILLING" />
...
</manifest>
Create your app on the Google Play Console, and add product in the In-App Products section. Make sure set the product status to active so that you can view or purchase it.
Note that testing Android In-App Billing is only possible after uploading your APK to Alpha release or above.
Add In-App Purchase capability on the Signing & Capabilities section of the project.
Create your app on the App Store Connect, and add product in the In-App Purchases section. You may need to fill the audit information field.
Testing iOS In-App Purchase is possible on TestFlight.
See full example here. This sample can also be downloaded from the Play Store.
Choose the product id that you want to sell. It would be nice to have the same product id for Android and iOS.
import InAppPurchase from "@class101/react-native-in-app-purchase";
const PRODUCT_IDS = [
"rniap.sample.normal",
"rniap.sample.consumable",
"rniap.sample.subscribe",
];
For some reasons, the purchase
and fetchProducts
functions are not Promise
. So you need to register onPurchase
and onFetchProducts
listeners.
const onFetchProducts = (products) => {
this.setState({ products });
};
const onPurchase = (purchase) => {
// Validate payment on your backend server with purchase object.
setTimeout(() => {
// Complete the purchase flow by calling finalize function.
InAppPurchase.finalize(
purchase,
purchase.productId === "rniap.sample.consumable"
).then(() => {
Alert.alert("In App Purchase", "Purchase Succeed!");
});
});
};
const onError = (e) => {
console.log(e);
};
InAppPurchase.onFetchProducts(onFetchProducts);
InAppPurchase.onPurchase(onPurchase);
InAppPurchase.onError(onError);
After each purchase, you need to verify receipt on your server. If purchase is valid, call finalize
function in the app. Set whether it is consumable or not as the second argument to the finalize
function.
Once you have registered your listeners, call configure
and fetchProducts
. Since the library will initialize billingClient only once, you can call configure
, fetchProducts
multiple times.
InAppPurchase.configure().then(() => {
InAppPurchase.fetchProducts(PRODUCT_IDS);
});
Call InAppPurchase.purchase
with product id.
InAppPurchase.purchase(item.productId); // 'rniap.sample.consumable'
Call InAppPurchase.restore
InAppPurchase.restore();
In some cases, your app may not be able to call the finalize
function even the purchase was successful. (such as poor internet connection) Purchases that are not finalized can be retrieved with the flush
function. Send these purchases to the server to verify, and then call the finalize
function.
InAppPurchase.flush().then((purchases) => {
purchases.forEach(onPurchase);
});
Type definitions of Product, Purchase and IAPError.
Property | Type | Comment |
---|---|---|
productId | string | - |
price | string | - |
currency | string | Currency code (USD, KRW...) |
title | string | - |
description | string | - |
Property | Type | Comment |
---|---|---|
productIds | string[] | - |
transactionId | string | - |
transactionDate | string | - |
receipt | string | Use this property to validate iOS purchase |
purchaseToken | string | Use this property to validate Android purchase |
Property | Type | Comment |
---|---|---|
type | FETCH_PRODUCTS, PURCHASE, CONNECTION | CONNECTION error is only occurs on Android. |
code | number | - |
message | string | - |
Actually this isn't something that should be mentioned in this document. However, since it's critical part of implementing In-App Purchase flow, I'll show you how I implemented it.
const onPurchase = useCallback(
(result: Purchase) => {
return verifyReceipt({
variables: {
input: {
platform: Platform.select({
ios: "apple",
android: "google",
}),
productId: result.productIds[0],
receipt: Platform.select({
ios: result.receipt,
android: result.purchaseToken,
}),
},
},
})
.then(() => InAppPurchase.finalize(result, true))
.then(() =>
Alert.alert(
LocalizedStrings.COIN_PURCHASE_SUCCESS_TITLE,
LocalizedStrings.COIN_PURCHASE_SUCCESS_MESSAGE
)
)
.catch((e) => Alert.alert(LocalizedStrings.COMMON_ERROR, e.message))
.finally(() => setIsLoading(false));
},
[setIsLoading, verifyReceipt]
);
Here I used node-iap library.
class VerifyReceipt extends Interactor<Params, Result> {
public async perform() {
const { user, platform, productId, receipt, language } = this.context;
if (!["apple", "google"].includes(platform)) {
throw new functions.https.HttpsError(
"invalid-argument",
LocalizedStrings(language).ERROR_RECEIPT_VALIDATE_FAILURE
);
}
return new Promise<Result>((resolve, reject) => {
iap.verifyPayment(
platform,
{
receipt,
productId,
packageName: PACKAGE_NAME,
keyObject: require("../../../iapServiceAccountKey.json"),
},
async (error, response) => {
if (error) {
return reject(
new functions.https.HttpsError("aborted", error.message)
);
}
// If it's already consumed
if (platform === "google" && response.receipt.purchase_state === 1) {
return reject(
new functions.https.HttpsError(
"unavailable",
LocalizedStrings(language).ERROR_RECEIPT_NOT_FOUND
)
);
}
const coin = COIN_LIST[language].find((c) => c.id === productId);
// Unavailable product
if (!coin) {
return reject(
new functions.https.HttpsError(
"unavailable",
LocalizedStrings(language).ERROR_RECEIPT_VALIDATE_FAILURE
)
);
}
const orderId = response.transactionId;
// If it's already verified
const userRef = firestore().collection("users").doc(user.id);
const transactionSnapshot = await userRef
.collection("transactions")
.where("orderId", "==", orderId)
.get();
if (transactionSnapshot.docs.length > 0) {
return resolve({
transaction: transactionSnapshot.docs[0].data() as Transaction,
profile: user,
});
}
const now = Date.now();
const batch = firestore().batch();
const receiptRef = userRef.collection("receipts").doc();
const transactionRef = userRef.collection("transactions").doc();
const transaction = {
id: transactionRef.id,
description: LocalizedStrings(language).DESCRIPTION_PURCHASE_COIN,
orderId,
amount: coin.amount,
createdAt: now,
};
batch.set(receiptRef, {
id: receiptRef.id,
productId,
orderId,
createdAt: now,
});
batch.set(
userRef,
{
coin: firestore.FieldValue.increment(coin.amount),
},
{
merge: true,
}
);
batch.set(transactionRef, transaction);
await batch.commit();
resolve({
transaction,
profile: {
...user,
coin: user.coin + coin.amount,
},
});
}
);
});
}
}
Note the part If it's already verified. Purchases that have already been verified should also return success. Otherwise, the item will be provided multiple times when flushing.
Bug reports and pull requests are welcome on GitHub.
The package is available as open source under the terms of the MIT License.