RevenueCat Web BillingとWeb SDKの登場により、iOS・Android・Web間でサブスクリプションを提供することがこれまでになく簡単になりました。すべてを単一のコードベースで実現でき、もはやプラットフォームごとの分断は不要です。ユーザーはどのプラットフォームからでもサブスクリプションに加入でき、プレミアム機能を全ての環境で利用できます。

このチュートリアルでは、RevenueCatのWeb Billingを活用して、iOS・Android・Web向けのReact Nativeアプリ(Expo使用)を構築します。サブスクリプションは3つのプラットフォーム間でシームレスに連携します。プラットフォームごとの個別コードは最小限に抑えつつ、主要なセットアップ手順を解説します。まだRevenueCatアカウントをお持ちでない場合は、まずアカウント作成から始め、3つのプラットフォーム用のプロダクト設定を行っていきます。

ステップ1 – サブスクリプション用プロダクトの設定

iOS、Android、Webでアプリの収益化を始めるには、まずRevenueCatのアカウントを作成しましょう。このアカウントは、サブスクリプションやプロダクト、カスタマーデータを一元管理するハブとなります。

  1. RevenueCatの公式サイトにアクセスします。
  2. ページ内の「サインアップ」または「無料で始める」ボタンをクリックします。
  3. 画面の指示に従って、メールアドレス、パスワード、会社情報などの必要事項を入力してアカウントを作成します。
  4. 指示に従ってメールアドレスの認証を行います。
  5. アカウントが作成できたら、RevenueCatのダッシュボードにアクセスできるようになります。ここからプロダクトの設定を行い、ExpoアプリへのSDK統合を進めていきます。

App Store ConnectとRevenueCatの連携についてはこちらのガイドを参照してください。AndroidとGoogle Play Consoleの接続方法もこちらのガイドで解説しています。

Web Billingを利用する場合は、Stripeアカウントが必要です。まだお持ちでない場合はStripeのアカウントを作成し、このガイドに従ってRevenueCatとStripeを連携させましょう。

各プラットフォームの設定が完了したら、次のステップへ進みます。

App Store ConnectでiOSのプロダクトを設定する

iOS向けに1ヶ月のサブスクリプションを設定するには、まずApp Store Connectにログインし、「My Apps(マイアプリ)」に移動します。対象のアプリを選択したら、「Features(機能)」タブを開き、「In-App Purchases(アプリ内課金)」セクションに進みます。右上の「+」ボタンをクリックし、「New Subscription(新規サブスクリプション)」を選択します。

サブスクリプションの参照名は 1_month_premium_ios とし、Product IDも同じ値を使用します。このサブスクリプションはサブスクリプショングループに紐づける必要があります。まだ作成していない場合は、この時点で新たに作成してください。期間は 1 Month選択し、料金は月額に相当するPricing Tier(価格ティア)を設定します。例えばTier 3などが該当します。

さらに、各言語ごとの表示名、アプリ内で表示されるサブスクリプションのスクリーンショット(テスト中は空の画像でもOK)、そして説明文を入力して、プロダクトを保存します。
なお、アプリを申請する際には、このサブスクリプションも審査に提出する必要があります。プロダクト情報は最新かつ正確である必要があるので注意してください。

続いてRevenueCatのダッシュボードに移動します。Products(プロダクト)タブ内で「+ New(新規追加)」をクリックし、先ほどApp Store Connectで作成したのと同じProduct ID 1_month_premium_iosを使って登録します。Store(ストア)は「App Store」を選び、Type(タイプ)は「Subscription(サブスクリプション)」を選択します。最後に、このプロダクトをEntitlement(エンタイトルメント)に紐づけます。たとえば premium という名前のエンタイトルメントを作成し、後ほどアプリ内でユーザーがプレミアム機能を利用できるかどうかの判定に使います。

iOSとApp Store Connectでのプロダクト設定の詳細については、RevenueCatの 公式iOSプロダクト設定ガイドも参照してください。

Google Play ConsoleでAndroidのプロダクトを設定する

まだ行っていない場合は、まず RevenueCatのGoogle Play連携ガイドに従って、RevenueCatとGoogle Playが連携できるように設定を行ってください。連携が正常に完了したら、Google Play Consoleの「アプリ」ページに移動し、対象のアプリを選択します。 サイドバーの「Products」メニューから「Subscriptions」を選択します。

「Create」ボタンをクリックしたら、サブスクリプションの名前とProduct IDを入力します。RevenueCatはこの一意のProduct IDを使ってサブスクリプションを同期します。
Product IDの命名は、<app><entitlement><version>のような形式がおすすめです。これは、一度使用したProduct IDは削除しても再利用できないためです。

次に、Base Planの追加を行います。ここで、課金期間、価格、更新タイプを設定します。iOSで作成したサブスクリプションと同じ内容にするのが望ましく、たとえば「1ヶ月」「自動更新」を選択します。
これで、RevenueCat側でサブスクリプションの利用が可能になります。
AndroidとGoogle Playでのプロダクト設定の詳細については、 RevenueCatのGoogle Playプロダクト設定ガイドも参照してください。

RevenueCatでWeb Billing用のプロダクトを設定する

Web Billing用の新しいプロダクトは、RevenueCatのダッシュボードから簡単に作成できます。プロジェクトの設定画面に移動し、「Products」タブで「New(新規追加)」をクリックし、Web Billing用のアプリを選択します。各入力項目は直感的に理解できる内容なので、iOSやAndroidで作成した月額サブスクリプションと同様の情報を入力しましょう。もし設定方法に迷った場合は、Web Billingのプロダクト設定ガイドを参照してください。

これで、RevenueCatのアカウントが作成され、iOS・Android・Webそれぞれのサブスクリプションが設定できました。各プロダクトはRevenueCat内の同じEntitlement、たとえば「premium」に紐づけられているため、プラットフォームをまたいでアクセスが共有されます。Web Billing用にStripeの接続も完了し、ダッシュボード上ですべてのプロダクトが正しく設定されていれば、いよいよ次はアプリへの実装に進む準備が整いました。

ステップ2 – React Nativeアプリに課金機能を追加する

注意:Expo Goは、ネイティブコードを含むreact-native-purchasesには対応していません。
このパッケージをReact Nativeプロジェクトで利用するには、Development Build(開発用ビルド)を作成する必要があります。Development Buildの詳しい情報はこちらをご覧ください。

プラットフォーム側の設定が完了したので、いよいよこれらを動かすコード部分に進みます。Expoを使用しているため、iOS・Android・Webの3つのプラットフォーム向けに簡単にコードを出力できます。

ただし、react-native-purchasesはWebには対応しておらず、同様に@revenuecat/purchases-jsはiOS/Androidのネイティブアプリには対応していません。そのため、React Nativeのプラットフォームごとのモジュール分岐(Platform-specific modules)の仕組みを活用します。具体的には、ファイルの拡張子によるプラットフォーム固有のコード分岐(Platform-specific extensions)を利用します。React NativeのMetro bundlerは、ファイル名の拡張子に.ios、.android、.native、.webを付けることで、各プラットフォームに適したファイルを自動的に判別して読み込んでくれます。
今回のケースでは、iOSとAndroidで特別に分ける必要はないため、iOSとAndroid共通のコードには.nativeを使い、Web用のコードには.webを使います。

react-native-purchasesの設定

まず、purchases.native.tsというファイルを作成し、次のコードをその中に記述します。

import { usePaymentsConfig } from "@\/hooks\/usePaymentsConfig";
import { useState } from "react";
import { useEffect } from "react";
import Purchases, {
  PurchasesPackage,
  CustomerInfo,
} from "react-native-purchases";

export const initializePayments = async (apiKey: string) => {
  await Purchases.configure({
    apiKey,
    appUserID: "perttu+3@lahteenlahti.com",
  });
};

export const usePackages = () => {
  const [packages, setPackages] = useState<PurchasesPackage[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchPackages = async () => {
      try {
        setIsLoading(true);
        const offerings = await Purchases.getOfferings();
        console.log("offerings", offerings);
        setPackages(offerings.current?.availablePackages ?? []);
      } catch (error) {
        console.error("Error fetching packages:", error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPackages();
  }, []);

  const purchasePackage = async (pkg: PurchasesPackage) => {
    await Purchases.purchasePackage(pkg);
  };

  return { packages, isLoading, purchasePackage };
};

export const useCustomerInfo = () => {
  const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchCustomerInfo = async () => {
      try {
        setIsLoading(true);
        const info = await Purchases.getCustomerInfo();
        setCustomerInfo(info);
      } catch (error) {
        console.error("Error fetching customer info:", error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchCustomerInfo();

    \/\/ Set up listener for customer info updates
    const customerInfoUpdated = (info: CustomerInfo) => {
      setCustomerInfo(info);
    };

    Purchases.addCustomerInfoUpdateListener(customerInfoUpdated);

    return () => {
      Purchases.removeCustomerInfoUpdateListener(customerInfoUpdated);
    };
  }, []);

  const hasActiveEntitlement = (entitlementId: string) => {
    return !!customerInfo?.entitlements.active[entitlementId];
  };

  return {
    customerInfo,
    isLoading,
    hasActiveEntitlement,
  };
};

このファイルには、iOSおよびAndroidアプリ向けの課金処理を行う3つの関数が含まれています。後ほど、この関数群のWeb版も作成することで、プラットフォームに依存しない共通のコードから同じ関数を呼び出せるようになります。React Nativeがプラットフォームごとに適切な関数を自動的に選んで実行してくれます。それでは、この仕組みを詳しく見ていきましょう。

ここでは、3つのカスタムフックを定義しています。

  • initializePaymentsは、アプリが起動したときにRevenueCatのSDKを初期化するためのフックです。
  • usePackagesは、利用可能なパッケージの表示と購入を行うためのフックです。
    このフックは、課金パッケージを表示する画面内で呼び出します。さらに、このフックはパッケージの購入処理を行う関数も返します。
  • useCustomerInfoは、ユーザーが正しいエンタイトルメント(権利)を持っているかどうかを確認し、その結果に応じてコンテンツへのアクセス可否を判断するためのフックです。

App.tsx ファイルの内容を以下のコードに置き換え、先ほど作成したカスタムフックを活用できるようにします。

export default function HomeScreen() {
  const { packages, isLoading, purchasePackage } = usePackages();
  return (
    <ScrollView style={styles.container}>
      {packages?.map((p) => (
        <PackageCard
          key={p.identifier}
          pkg={p}
          purchasePackage={purchasePackage}
        \/>
      ))}
    <\/ScrollView>
  );
}

また、新しくPackageCardというファイルを作成し、次のようなコンポーネントを実装します。

import { StyleSheet, TouchableOpacity } from "react-native";
import { PurchasesPackage } from "react-native-purchases";
import { ThemedView } from ".\/ThemedView";
import { ThemedText } from ".\/ThemedText";

type Props = {
  pkg: PurchasesPackage;
  purchasePackage: (pkg: PurchasesPackage) => void;
};

export const PackageCard = ({ pkg, purchasePackage }: Props) => {
  return (
    <TouchableOpacity
      key={pkg.identifier}
      style={styles.card}
      onPress={() => purchasePackage(pkg)}
    >
      <ThemedView style={styles.cardContent}>
        <ThemedText style={styles.packageTitle}>{pkg.product.title}<\/ThemedText>
        <ThemedText style={styles.packageType}>
          {pkg.product.description}
        <\/ThemedText>
        <ThemedText style={styles.price}>{pkg.product.priceString}<\/ThemedText>
      <\/ThemedView>
    <\/TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  titleContainer: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
  },
  stepContainer: {
    gap: 8,
    marginBottom: 8,
    padding: 8,
  },
  reactLogo: {
    height: 178,
    width: 290,
    bottom: 0,
    left: 0,
    position: "absolute",
  },
  headerText: {
    fontSize: 24,
    marginBottom: 16,
    fontWeight: "600",
  },
  card: {
    marginVertical: 8,
    borderRadius: 12,
    overflow: "hidden",
    elevation: 2,
    boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)",
  },
  cardContent: {
    padding: 16,
    borderRadius: 12,
  },
  packageTitle: {
    fontSize: 18,
    fontWeight: "600",
    marginBottom: 8,
  },
  packageType: {
    fontSize: 14,
    opacity: 0.7,
    marginBottom: 8,
  },
  price: {
    fontSize: 20,
    fontWeight: "700",
  },
});

これで、ネイティブアプリ(iOS/Android)向けの課金処理が動作する状態になりました。実際にシミュレーターやエミュレーターでアプリを起動して確認してみましょう。設定したサブスクリプションが表示され、パッケージをタップすると購入フローが開始されるはずです。
サブスクリプションのテストの詳細については、AndroidのテストのドキュメントとiOSのテストのドキュメントを参照してください。

iOSとAndroidでサブスクリプションをサポートするために、purchases.native.tsファイル内でreact-native-purchasesを使用しました。このファイルには、RevenueCatの初期化、パッケージの取得、購入処理、そしてエンタイトルメントの監視が含まれています。これらの共通のカスタムフックによって、ネイティブ側のロジックはシンプルかつ一貫性のあるものになり、そのままExpoアプリのUIに組み込むことができるようになっています。

ステップ3 – React NativeプロジェクトにWeb Billingを追加する

iOSとAndroid版のアプリで動作する課金コードが完成したので、次はWeb版にも対応させていきます。Web上でも購入ができるように設定しましょう。RevenueCatはこのようなケースに最適で、Webでのサブスクリプション販売を簡単に始められるだけでなく、モバイルアプリのサブスクリプションやエンタイトルメントとシームレスに連携することができます。

ユーザーがWebアプリ上でもサブスクリプションに加入できるようにするために、今回はWeb SDKを使って購入処理を初期化します。このWeb SDKは、react-native-purchasesと同じような形で動作し、ほぼ同じAPIを提供しています。ただし、購入の流れはモバイル版とは少しだけ異なります。

次のコマンドを実行して、プロジェクトにパッケージをインストールします。

npm install --save @revenuecat/purchases-js

purchases.jsの設定

まず、purchases.web.tsという新しいファイルを作成し、次のコードを記述します。

import { usePaymentsConfig } from "@\/hooks\/usePaymentsConfig";
import {
  CustomerInfo,
  ErrorCode,
  Package,
  Purchases,
} from "@revenuecat\/purchases-js";
import { useEffect, useState } from "react";

export const initializePayments = async (apiKey: string) => {
  const appUserId = Purchases.generateRevenueCatAnonymousAppUserId();
  Purchases.configure(apiKey, appUserId);
};

export const offeringId = "default";

export const webReset = () => {
  \/\/ Remove all styles from the html and body tags after completing RevenueCat purchase
  \/\/ this is needed as during the purchase process, the body tag is styled with styles which
  \/\/ override the default styles of the Expo app
  ["html", "body"].forEach((tag) =>
    document.querySelector(tag)?.removeAttribute("style")
  );
};

export const usePackages = () => {
  const { isConfigured } = usePaymentsConfig();
  const [packages, setPackages] = useState<Package[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!isConfigured) return;

    const fetchPackages = async () => {
      const offerings = await Purchases.getSharedInstance().getOfferings();
      setPackages(offerings.all[offeringId].availablePackages);
    };

    fetchPackages();
  }, [isConfigured]);

  const purchasePackage = async (pkg: Package) => {
    try {
      const { customerInfo } = await Purchases.getSharedInstance().purchase({
        customerEmail: "perttu+3@lahteenlahti.com",
        rcPackage: pkg,
      });
      return customerInfo;
    } catch (error) {
      console.log(error);
      if (error === ErrorCode.UserCancelledError) {
        return null;
      }
    } finally {
      webReset();
    }
  };

  return { packages, isLoading, purchasePackage };
};

export const useCustomerInfo = () => {
  const isConfigured = usePaymentsConfig();
  const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!isConfigured) return;

    const fetchCustomerInfo = async () => {
      try {
        setIsLoading(true);
        const info = await Purchases.getSharedInstance().getCustomerInfo();
        setCustomerInfo(info);
      } catch (error) {
        console.error("Error fetching customer info:", error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchCustomerInfo();
  }, [isConfigured]);

  const hasActiveEntitlement = (entitlementId: string) => {
    return !!customerInfo?.entitlements.active[entitlementId];
  };

  return {
    customerInfo,
    isLoading,
    hasActiveEntitlement,
  };
};

それでは、このコードの各部分が何をしているのか、順番に確認していきましょう。

まず最初に**initializePaymentsがあります。この関数はアプリ内で一度だけ呼び出すべきもので、App.tsx内のuseEffectのマウント時に配置するのが適切です。
この関数の内部を詳しく見てみると、主に2つの処理を行っています。
1つ目は匿名のuserIdを生成すること。
2つ目はそのIDを使ってSDKの初期化を行うことです。
もし、iOSで購入したサブスクリプションをWebアプリでも自動的に認識させたい場合は、自分のアプリの認証システムで発行するIDを使って初期化するのがベストです。こうすることで、どのプラットフォームでも同じユーザーとしてサブスクリプションが正しく紐づきます。

購入フローが終了した後に呼び出すためのresetStylesという関数を追加しています。これは、現在のところExpo RouterのスタイルがRevenueCatのWeb Billingのスタイルと競合してしまい、見た目が少し崩れてしまうことがあるためです。そのため、購入フローの完了後にスタイルをリセットする処理が必要になります。

export const webReset = () => {
\u00a0\u00a0["html", "body"].forEach((tag) =>
\u00a0\u00a0\u00a0\u00a0document.querySelector(tag)?.removeAttribute("style")
\u00a0\u00a0);
};

その他のコード部分は、iOSやAndroid向けの実装と非常によく似ています。これでアプリを起動してWeb版を開けば、利用可能なサブスクリプションが表示され、購入できるようになっているはずです。

まとめ

少しの設定とプラットフォームごとのコード分岐を加えるだけで、iOS・Android・Webすべてでサブスクリプションを販売できるReact Nativeアプリが完成しました。App Store Connect、Google Play Console、そしてStripeでそれぞれのプロダクトを設定し、RevenueCat内で共通のエンタイトルメントに紐づけることができました。プラットフォームごとのモジュール分岐と統一されたAPIのおかげで、アプリのロジックはクリーンかつ一貫性のある状態を保つことができます。

次のステップは、サブスクリプションのテストです。
ちょうど良いことに、各プラットフォーム向けのテストガイドを用意しています。