構築するもの

多くのアプリは、2種類の通貨で成り立っています。プレイを通じて獲得するソフトカレンシー(コイン、エネルギー、 XP)と、実際のお金で購入するハードカレンシー(ジェム、クレジット、トークン)です。このコードラボでは、 RevenueCat Virtual Currencies を土台に、そのハードカレンシー側である「ジェム」ストアを構築します。 対象は iOS・Android・Flutter・React Native・Kotlin Multiplatform です。

1つのコードラボで、すべてのプラットフォームに対応。 各クライアントのコードブロックにはプラットフォーム切り替えが付いています。 どのタブでもプラットフォームを選ぶと、コードラボ内のすべてのクライアントスニペットがそれに合わせて切り替わるので、 自分の使う言語で最初から最後まで読み進められます。バックエンドとダッシュボードの手順はどのプラットフォームでも共通です。

最終的に、次のものが手に入ります。

  • 消費型のアプリ内課金で加算される、RevenueCat 上の GEM 仮想通貨。
  • ジェムパックをローカライズされたストア価格で一覧表示する、ネイティブなストア画面。
  • RevenueCat がレシートを検証したあとにジェム残高を自動加算する購入処理。
  • SDK から読み取り、アプリに表示するリアルタイムのジェム残高。
  • Secret API キーでジェムを差し引き、残高不足もきれいに処理するバックエンドの「消費」エンドポイント。

最初の決断: 各通貨をどこで管理するか

コードを書く前に、残高の信頼できる唯一の情報源(source of truth)を決めます。 多くのチームは、すでに自前のサーバーでソフトカレンシーを運用した状態でここにたどり着き、両方の通貨を1か所にまとめるべきか悩みます。 構成は2通りあります。

方式 ソフトカレンシー ハードカレンシー
A. 分離(このケースで推奨) 自前のサーバー RevenueCat
B. 統合 RevenueCat RevenueCat

すでに動作するソフトカレンシー用サーバーがあるなら、方式A のほうがリスクは低くなります。 高頻度でゲームプレイ主導のソフト残高は、すでにうまく動いている場所にそのまま残し、ハードカレンシーは RevenueCat に任せます。 RevenueCat がもっとも価値を発揮するのがこの部分です。レシート検証、自動加算、返金時の自動巻き戻し、アトミックな消費、監査証跡が手に入ります。 既存のソフト残高を移行する必要もありません。

方式B(両方の通貨で RevenueCat を単一の信頼できる情報源とする方式)は、既存の残高ストレージを持たない 新規アプリに対して RevenueCat が推奨する方式です。 すべての残高が1つのシステムに集まるため、ゼロから始める場合はいちばんシンプルなモデルになります。

通貨ごとに信頼できる情報源を1つに絞り、1つの通貨を複数のシステムにまたがせないでください。 危険なのは、通貨の「本物の」残高を自前のサーバーに置きつつ、それを RevenueCat にミラーリングする(あるいはその逆をする)ケースです。 1つの残高に対して書き込み手が2つあると、値のずれや突き合わせの手間が生じます。このコードラボは方式 A に従い、 ジェム残高は RevenueCat が最初から最後まで管理します。

ここでは RevenueCat の決済エンジンと残高を使いつつ、ストアの UI は自前のネイティブ実装にします (RevenueCat のペイウォール UI は不要です)。こうすれば、すでに設計したソフトカレンシーのストアと、ジェムの店構えの見た目を揃えられます。

対象読者と必要なもの

ゲームやソーシャルアプリに消費型のハードカレンシーを追加するモバイル開発者向けです。 自分のプラットフォームの UI と非同期モデルに慣れていて、App Store Connect または Google Play にアプリをセットアップ済みであることが前提です (実際のアプリでも、ローカルテスト用の StoreKit 構成ファイルやライセンステスターでもかまいません)。 Virtual Currencies には、比較的新しい SDK が必要です。

プラットフォーム SDK 最低バージョン
iOSpurchases-ios5.32.0
Androidpurchases-android9.1.0
Flutterpurchases_flutter9.1.0
React Nativereact-native-purchases9.1.0
Kotlin Multiplatformpurchases-kmp2.1.0+16.2.0

仮想通貨を作成する

RevenueCat ダッシュボードで、 プロジェクトの Product catalog を開いて Virtual Currencies を選び、 + New virtual currency をクリックします。重要なフィールドは2つです。

  • Code: SDK と API で使う識別子(たとえば GEM)。コード内で参照するので、慎重に決めてください。
  • Name: 表示名(たとえば Gems)。

任意でアイコンと説明も追加できます。保存すると、その通貨がすべてのユーザーに対して残高 0 の状態で作成されます。

知っておくと便利な点。 1つのプロジェクトは最大 100 個の仮想通貨をサポートし、単一の残高は最大 2,000,000,000 まで、残高がマイナスになることはありません(残高が 0 を下回るような差し引きは拒否されます。消費のステップで確認します)。 Virtual Currencies は RevenueCat の Pro プランに含まれます。

ジェムパックの商品と付与量を作成する

ジェムは実際のお金で購入するので、各ジェムパックは消費型のアプリ内課金です。 ここはすべてダッシュボードとストアの構成で、まだコードは書きません。

1. ストアで消費型の商品を作成する

App Store Connect や Google Play Console で、パックごとに消費型の商品を1つずつ作成します(たとえば gems_300gems_1200gems_6500)。それぞれに価格帯を設定します。 消費型の商品は繰り返し購入でき、これはまさに通貨のチャージに必要な性質です。

2. 商品をインポートしてオファリングに入れる

RevenueCat で、これらの商品をアプリの下に追加し、オファリング(たとえば gems)を作成して、各商品をパッケージとして追加します。オファリングは、アプリが実行時に購入可能なパックを取得するしくみで、 アプリを更新せずにあとからパックの並べ替えや入れ替えができます。

3. 各商品を GEM 通貨に関連付ける

GEM 通貨を開いて Add associated product をクリックし、ジェムパックの商品を選んで、 付与する数量を入力します。たとえば次のとおりです。

商品 付与量
gems_300300 GEM
gems_12001200 GEM
gems_65006500 GEM

これ以降、ユーザーがこれらの商品を購入するたびに、RevenueCat がストアのレシートを検証し、 設定した数量をジェム残高に自動的に加算します。加算のコードを書く必要はありません。

返金は自動で巻き戻ります。 消費型の購入が返金された場合(RevenueCat が返金を検知できるよう、アプリ内課金キーを設定してください)、 RevenueCat は付与した通貨のうち日割り相当分を差し引き、残高がマイナスにならないよう 0 で下限を設けます。 この返金処理は、購入コールバックから自分でジェムを加算するのではなく、ハードカレンシーの管理を RevenueCat に任せる主な理由の1つです。

SDK をインストールして設定する

プラットフォーム向けの RevenueCat SDK をインストールし、アプリ起動時に公開 API キーで一度だけ設定します。 キーの接頭辞は、ビルドの配信先ストアによって変わります。Apple のキーは appl_Google のキーは goog_Amazonamzn_ で始まります。 クロスプラットフォーム SDK では、実行時にプラットフォームごとに適切なキーを選びます。

下のプラットフォーム切り替えを使ってください。 どのコードブロックでもプラットフォームを選ぶと、 このコードラボの残りにあるすべてのクライアントスニペットがそれに追従します。(プラットフォームごとの最低 SDK バージョンはステップ 1 を参照してください。)
swift
// GemStoreApp.swift
import SwiftUI
import RevenueCat

@main
struct GemStoreApp: App {
    init() {
        Purchases.logLevel = .debug
        Purchases.configure(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY")
    }

    var body: some Scene {
        WindowGroup {
            GemStoreView()
        }
    }
}
kotlin
// App.kt
import android.app.Application
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Purchases.logLevel = LogLevel.DEBUG
        Purchases.configure(
            PurchasesConfiguration.Builder(this, "goog_YOUR_PUBLIC_SDK_KEY").build()
        )
    }
}
dart
// main.dart
import 'dart:io' show Platform;
import 'package:purchases_flutter/purchases_flutter.dart';

Future<void> configureRevenueCat() async {
  await Purchases.setLogLevel(LogLevel.debug);
  final config = Platform.isIOS
      ? PurchasesConfiguration("appl_YOUR_PUBLIC_SDK_KEY")
      : PurchasesConfiguration("goog_YOUR_PUBLIC_SDK_KEY");
  await Purchases.configure(config);
}
typescript
// revenuecat.ts
import { Platform } from 'react-native';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';

export function configureRevenueCat() {
  Purchases.setLogLevel(LOG_LEVEL.DEBUG);
  Purchases.configure({
    apiKey: Platform.OS === 'ios' ? 'appl_YOUR_PUBLIC_SDK_KEY' : 'goog_YOUR_PUBLIC_SDK_KEY',
  });
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.LogLevel
import com.revenuecat.purchases.kmp.Purchases

// Each target supplies the key (appl_ on iOS, goog_ on Android).
expect val revenueCatApiKey: String

fun configureRevenueCat() {
    Purchases.logLevel = LogLevel.DEBUG
    Purchases.configure(apiKey = revenueCatApiKey)
}

ユーザーを識別する(残高はこれに紐づきます)

仮想通貨の残高は、App User ID で識別されるユーザーに属します。何もしなければ、 RevenueCat は匿名 ID を割り当てるので、そこで購入したジェムはその匿名ユーザーに紐づき、新しい端末や再インストール後のユーザーには引き継がれません。 ユーザーがサインインしたらすぐに、自前の安定したユーザー ID を渡して logIn を呼び出せば、 どこでもそのジェム残高がユーザーのものになります。

swift
// After your own auth resolves a user id:
let (_, created) = try await Purchases.shared.logIn("your-stable-user-id")
print("RevenueCat customer ready (new customer: \(created))")
// Use this SAME id on your backend when you spend gems (see step 8).
kotlin
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitLogIn

// Use this SAME id on your backend when you spend gems (see step 8).
// LogInResult is a Poko class (no destructuring); read the property.
val result = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = result.created
dart
import 'package:purchases_flutter/purchases_flutter.dart';

// Use this SAME id on your backend when you spend gems (see step 8).
final LogInResult result = await Purchases.logIn("your-stable-user-id");
final bool created = result.created;
typescript
import Purchases from 'react-native-purchases';

// Use this SAME id on your backend when you spend gems (see step 8).
const { created } = await Purchases.logIn('your-stable-user-id');
kotlin
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitLogIn

// Use this SAME id on your backend when you spend gems (see step 8).
val login = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = login.created
App User ID は結合キーです。 同じ ID が、クライアント、RevenueCat 上のジェム残高、バックエンドの消費呼び出しを結び付けます。 メールアドレスではなくバックエンドのユーザー ID を使ってください(メールアドレスは変わります)。ログインとログアウトの詳細は、 ユーザーの識別ガイドを参照してください。

ストアを構築する

gems オファリングを取得し、各パッケージをローカライズされた価格文字列とともに表示します。 RevenueCat は、ユーザーのストアフロント通貨に合わせて整形済みの価格を返すので、価格をハードコードしてはいけません。 ここで参照している buy 関数と refreshBalance 関数は、続く2つのステップで追加します。

swift
// GemStoreModel.swift
import SwiftUI
import RevenueCat

@MainActor
final class GemStoreModel: ObservableObject {
    @Published var packages: [Package] = []
    @Published var gemBalance: Int = 0
    @Published var errorMessage: String?

    /// Load the purchasable gem packs.
    func loadStore() async {
        do {
            let offerings = try await Purchases.shared.offerings()
            // Named "gems" offering, or fall back to the current one.
            let offering = offerings.all["gems"] ?? offerings.current
            packages = offering?.availablePackages ?? []
        } catch {
            errorMessage = "Could not load the store: \(error.localizedDescription)"
        }
    }
    // buy(_:) and refreshBalance() are added in the next steps.
}
swift
// GemStoreView.swift
import SwiftUI
import RevenueCat

struct GemStoreView: View {
    @StateObject private var model = GemStoreModel()

    // A real two-way binding, so any dismissal clears the error state.
    private var showError: Binding<Bool> {
        Binding(get: { model.errorMessage != nil },
                set: { if !$0 { model.errorMessage = nil } })
    }

    var body: some View {
        NavigationStack {
            List {
                Section("Your balance") {
                    Label("\(model.gemBalance) gems", systemImage: "diamond.fill")
                        .font(.headline)
                }
                Section("Buy gems") {
                    ForEach(model.packages, id: \.identifier) { pkg in
                        Button {
                            Task { await model.buy(pkg) }
                        } label: {
                            HStack {
                                Text(pkg.storeProduct.localizedTitle)
                                Spacer()
                                Text(pkg.storeProduct.localizedPriceString)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Gem Store")
            .task {
                await model.loadStore()
                await model.refreshBalance()
            }
            .alert("Something went wrong", isPresented: showError) {
                Button("OK", role: .cancel) { }
            } message: {
                Text(model.errorMessage ?? "")
            }
        }
    }
}
kotlin
// GemStoreViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitOfferings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class GemStoreViewModel : ViewModel() {
    data class UiState(
        val packages: List<Package> = emptyList(),
        val gemBalance: Int = 0,
        val error: String? = null,
    )
    private val _state = MutableStateFlow(UiState())
    val state = _state.asStateFlow()

    init {
        viewModelScope.launch {
            val offerings = Purchases.sharedInstance.awaitOfferings()
            val offering = offerings.all["gems"] ?: offerings.current
            _state.update { it.copy(packages = offering?.availablePackages ?: emptyList()) }
            refreshBalance()
        }
    }
    // buy(...) and refreshBalance() are added in the next steps.
}
kotlin
// GemStoreScreen.kt
import android.app.Activity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun GemStoreScreen(vm: GemStoreViewModel, activity: Activity) {
    val ui by vm.state.collectAsState()
    LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
        item {
            Text("${ui.gemBalance} gems", style = MaterialTheme.typography.headlineSmall)
            Spacer(Modifier.height(16.dp))
        }
        items(ui.packages, key = { it.identifier }) { pkg ->
            ListItem(
                headlineContent = { Text(pkg.product.title) },
                trailingContent = { Text(pkg.product.price.formatted) },
                modifier = Modifier.clickable { vm.buy(pkg, activity) }
            )
        }
    }
}
dart
// gem_store_page.dart
import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

class GemStorePage extends StatefulWidget {
  const GemStorePage({super.key});
  @override
  State<GemStorePage> createState() => _GemStorePageState();
}

class _GemStorePageState extends State<GemStorePage> {
  List<Package> _packages = [];
  int _gemBalance = 0;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    final offerings = await Purchases.getOfferings();
    final offering = offerings.all["gems"] ?? offerings.current;
    if (mounted) setState(() => _packages = offering?.availablePackages ?? []);
    await refreshBalance();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Gem Store')),
      body: ListView(
        children: [
          ListTile(
            title: Text('$_gemBalance gems',
                style: Theme.of(context).textTheme.headlineSmall),
          ),
          const Divider(),
          for (final pkg in _packages)
            ListTile(
              title: Text(pkg.storeProduct.title),
              trailing: Text(pkg.storeProduct.priceString),
              onTap: () => buy(pkg),
            ),
        ],
      ),
    );
  }
  // buy() and refreshBalance() are added in the next steps.
}
tsx
// GemStoreScreen.tsx
import React, { useEffect, useState } from 'react';
import { FlatList, Text, TouchableOpacity, View } from 'react-native';
import Purchases, { PurchasesPackage } from 'react-native-purchases';

export function GemStoreScreen() {
  const [packages, setPackages] = useState<PurchasesPackage[]>([]);
  const [gemBalance, setGemBalance] = useState(0);

  useEffect(() => {
    (async () => {
      const offerings = await Purchases.getOfferings();
      const offering = offerings.all['gems'] ?? offerings.current;
      setPackages(offering?.availablePackages ?? []);
      await refreshBalance(setGemBalance);
    })();
  }, []);

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: '600' }}>{gemBalance} gems</Text>
      <FlatList
        data={packages}
        keyExtractor={(p) => p.identifier}
        renderItem={({ item: pkg }) => (
          <TouchableOpacity
            onPress={() => buy(pkg, gemBalance, setGemBalance)}
            style={{ flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12 }}
          >
            <Text>{pkg.product.title}</Text>
            <Text style={{ color: '#888' }}>{pkg.product.priceString}</Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}
kotlin
// GemStoreScreen.kt (commonMain, Compose Multiplatform)
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitOfferings
import com.revenuecat.purchases.kmp.models.Package
import kotlinx.coroutines.launch

@Composable
fun GemStoreScreen() {
    val scope = rememberCoroutineScope()
    var packages by remember { mutableStateOf<List<Package>>(emptyList()) }
    var gemBalance by remember { mutableStateOf(0) }

    LaunchedEffect(Unit) {
        val offerings = Purchases.sharedInstance.awaitOfferings()
        val offering = offerings.all["gems"] ?: offerings.current
        packages = offering?.availablePackages ?: emptyList()
        gemBalance = refreshBalance()
    }

    LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
        item {
            Text("$gemBalance gems", style = MaterialTheme.typography.headlineSmall)
            Spacer(Modifier.height(16.dp))
        }
        items(packages, key = { it.identifier }) { pkg ->
            ListItem(
                headlineContent = { Text(pkg.storeProduct.title) },
                trailingContent = { Text(pkg.storeProduct.price.formatted) },
                modifier = Modifier.clickable { scope.launch { gemBalance = buy(pkg, gemBalance) } }
            )
        }
    }
}
UI は自前、エンジンは RevenueCat。 これは「エンジンのみ」を使う経路です。offerings API で商品を取得し、 好きなように表示できるので、すでに作ったソフトカレンシーのストアにジェムストアの見た目を合わせられます。 RevenueCat の Paywalls UI SDK は任意で、ここでは使いません。

ジェムを購入する

選択したパッケージで purchase API を呼び出します。RevenueCat はストアの購入を実行してレシートを検証し、 その商品が GEM に関連付けられているため、自身のサーバー上でジェムを自動的に加算します。 やることは、キャンセルを処理してから残高を更新するだけです。

swift
// GemStoreModel.swift (continued)
extension GemStoreModel {
    func buy(_ package: Package) async {
        do {
            let result = try await Purchases.shared.purchase(package: package)
            guard !result.userCancelled else { return } // user closed the sheet

            // Gems are credited on RevenueCat's servers a beat after purchase()
            // returns, so poll briefly until the new balance lands.
            let before = gemBalance
            for _ in 0..<5 {
                await refreshBalance()
                if gemBalance > before { break }
                try? await Task.sleep(for: .milliseconds(500))
            }
        } catch {
            errorMessage = "Purchase failed: \(error.localizedDescription)"
        }
    }
}
kotlin
// Add to GemStoreViewModel
import android.app.Activity
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.awaitPurchase
import kotlinx.coroutines.delay

fun buy(pkg: Package, activity: Activity) = viewModelScope.launch {
    try {
        Purchases.sharedInstance.awaitPurchase(
            PurchaseParams.Builder(activity, pkg).build()
        )
        // Poll briefly until the credited balance lands.
        val before = state.value.gemBalance
        repeat(5) {
            refreshBalance()
            if (state.value.gemBalance > before) return@launch
            delay(500)
        }
    } catch (e: PurchasesException) {
        if (e.code == PurchasesErrorCode.PurchaseCancelledError) return@launch
        _state.update { it.copy(error = "Purchase failed: ${e.message}") }
    }
}
dart
// Add to _GemStorePageState
import 'package:flutter/services.dart' show PlatformException;

Future<void> buy(Package package) async {
  try {
    await Purchases.purchasePackage(package);
    // Poll briefly until the credited balance lands.
    final before = _gemBalance;
    for (var i = 0; i < 5; i++) {
      await refreshBalance();
      if (_gemBalance > before) break;
      await Future.delayed(const Duration(milliseconds: 500));
    }
  } on PlatformException catch (e) {
    final code = PurchasesErrorHelper.getErrorCode(e);
    if (code == PurchasesErrorCode.purchaseCancelledError) return; // closed the sheet
    // surface "Purchase failed" as you prefer
  }
}
typescript
import Purchases, { PurchasesPackage, PURCHASES_ERROR_CODE } from 'react-native-purchases';

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function buy(
  pkg: PurchasesPackage,
  before: number,
  setGemBalance: (n: number) => void,
) {
  try {
    await Purchases.purchasePackage(pkg);
    // Poll briefly until the credited balance lands.
    for (let i = 0; i < 5; i++) {
      const balance = await refreshBalance(setGemBalance);
      if (balance > before) break;
      await sleep(500);
    }
  } catch (e: any) {
    if (e.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) return; // closed the sheet
    // surface "Purchase failed" as you prefer
  }
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.PurchasesException
import com.revenuecat.purchases.kmp.ktx.awaitPurchase
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
import kotlinx.coroutines.delay

suspend fun buy(pkg: Package, before: Int): Int {
    return try {
        Purchases.sharedInstance.awaitPurchase(packageToPurchase = pkg)
        // Poll briefly until the credited balance lands.
        var balance = before
        repeat(5) {
            balance = refreshBalance()
            if (balance > before) return balance
            delay(500)
        }
        balance
    } catch (e: PurchasesException) {
        if (e.error.code == PurchasesErrorCode.PurchaseCancelledError) before // closed the sheet
        else before // surface "Purchase failed" as you prefer
    }
}
このコールバックの中で、自分でジェムを加算しないでください。 RevenueCat が検証済みのレシートに基づいてすでに付与しています。 ここでジェムをもう一度加算すると二重加算になり、さらに悪いことに返金時に巻き戻されません。 SDK の残高を正として扱い、再取得するだけにしてください。
残高は結果整合的です。 付与は、purchase の呼び出しが返った直後に RevenueCat のサーバー側で反映されます。 そのため buy は一度だけ読み取るのではなくポーリングします。アプリがフォアグラウンドに戻ったときにも残高を更新しておけば、 取りこぼした更新が自然に修復されます。同じ更新は、完了した購入がユーザーキャンセルとして報告されるまれなケースもカバーします。

残高を表示する

virtual currencies API で残高を読み取り、all マップからコードを使って対象の通貨を取り出します。 残高は SDK によってキャッシュされ、自動では更新されません。そのため、残高を変える操作(購入やバックエンドの消費)のあとは、 キャッシュを無効化して再取得してください。

swift
// GemStoreModel.swift (continued)
extension GemStoreModel {
    /// Invalidate first so we never show a stale value after a purchase or spend.
    func refreshBalance() async {
        do {
            Purchases.shared.invalidateVirtualCurrenciesCache()
            let currencies = try await Purchases.shared.virtualCurrencies()
            gemBalance = currencies.all["GEM"]?.balance ?? 0
        } catch {
            errorMessage = "Could not load your balance: \(error.localizedDescription)"
        }
    }
}
kotlin
// Add to GemStoreViewModel
import com.revenuecat.purchases.awaitGetVirtualCurrencies

suspend fun refreshBalance() {
    try {
        Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
        val currencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()
        _state.update { it.copy(gemBalance = currencies.all["GEM"]?.balance ?: 0) }
    } catch (e: Exception) {
        _state.update { it.copy(error = "Could not load your balance: ${e.message}") }
    }
}
dart
// Add to _GemStorePageState
Future<void> refreshBalance() async {
  try {
    await Purchases.invalidateVirtualCurrenciesCache();
    final currencies = await Purchases.getVirtualCurrencies();
    if (mounted) setState(() => _gemBalance = currencies.all["GEM"]?.balance ?? 0);
  } catch (e) {
    // surface error as you prefer
  }
}
typescript
import Purchases from 'react-native-purchases';

export async function refreshBalance(
  setGemBalance: (n: number) => void,
): Promise<number> {
  await Purchases.invalidateVirtualCurrenciesCache();
  const currencies = await Purchases.getVirtualCurrencies();
  const balance = currencies.all['GEM']?.balance ?? 0;
  setGemBalance(balance);
  return balance;
}
kotlin
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitVirtualCurrencies

suspend fun refreshBalance(): Int {
    Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
    val currencies = Purchases.sharedInstance.awaitVirtualCurrencies()
    return currencies.all["GEM"]?.balance ?: 0
}

最初の描画を速くしたい場合は、ネットワークの応答が返る前にキャッシュ値を読み取れます。

swift
// iOS exposes a synchronous cached property (nil before the first fetch):
let cached = Purchases.shared.cachedVirtualCurrencies?.all["GEM"]?.balance
kotlin
// Android also exposes a synchronous cached property (null before the first fetch):
val cached = Purchases.sharedInstance.cachedVirtualCurrencies?.all["GEM"]?.balance
dart
// Flutter's cached accessor is an async method (not a property):
final cached = (await Purchases.getCachedVirtualCurrencies())?.all["GEM"]?.balance;
typescript
// React Native's cached accessor is an async method (not a property):
const cached = (await Purchases.getCachedVirtualCurrencies())?.all['GEM']?.balance;
kotlin
// KMP's cached accessor is a method (not a property):
val cached = Purchases.sharedInstance.getCachedVirtualCurrencies()?.all["GEM"]?.balance
キャッシュのアクセサはプラットフォームごとに異なります。 iOSAndroid では同期プロパティですが、 FlutterReact NativeKMP では非同期メソッド(getCachedVirtualCurrencies())です。 各 VirtualCurrencybalance のほかに codenameserverDescription も持つので、 アプリ内で「Gems」をハードコードせず、ダッシュボードからラベルを反映できます。

ジェムを消費する (バックエンド)

ジェムの消費は、RevenueCat の Secret API キーsk_ で始まります)を使って、 自分が管理するサーバーで行う必要があります。シークレットキーは残高を動かせるので、アプリに同梱してはいけません。 流れは次のとおりです。アプリがバックエンドに消費を依頼し、バックエンドが RevenueCat を通じて差し引きます。 ユーザーが支払えたかどうかは、この差し引きの成否そのものが決めます。

差し引きエンドポイント(バックエンド)

ユーザーの virtual currency transactions エンドポイントに adjustments マップを POST します。負の数で消費し、正の数で付与します。 マップ全体はアトミックに適用されます。マップ内のいずれかの通貨で残高が足りなければ、何も差し引かれず、 RevenueCat は HTTP 422 を返します。価格はアイテム名からサーバーが決めます。 クライアントが送ってきた金額を差し引くことはありません。この部分は、アプリのプラットフォームに関係なく共通です。

javascript
// POST /spend  (Node serverless handler)
// Env: RC_SECRET_KEY (sk_...), RC_PROJECT_ID. Never expose these to the client.

// The server owns the price list. Never deduct an amount the client sends:
// a tampered client could buy a 5000-gem item for 1 gem.
const GEM_PRICES = { extra_life: 50, legendary_skin: 5000 };

export async function POST(req) {
  const { item, idempotencyKey } = await req.json();

  // Derive the customer from the authenticated session, NOT from the request body.
  // The client must not be able to spend another user's gems by sending their id.
  const appUserId = await getUserIdFromSession(req); // your auth

  const cost = GEM_PRICES[item];
  if (!Number.isInteger(cost) || cost <= 0) {
    return Response.json({ error: "Unknown item" }, { status: 400 });
  }

  const url =
    `https://api.revenuecat.com/v2/projects/${process.env.RC_PROJECT_ID}` +
    `/customers/${encodeURIComponent(appUserId)}/virtual_currencies/transactions`;

  const headers = {
    Authorization: `Bearer ${process.env.RC_SECRET_KEY}`,
    "Content-Type": "application/json",
    // The SAME key on a retry is applied once, so a network retry can't double-spend.
    "Idempotency-Key": idempotencyKey,
  };

  const rcRes = await fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify({ adjustments: { GEM: -cost } }),
  });

  // 422 = balance too low. The deduction is atomic, so nothing was taken.
  if (rcRes.status === 422) {
    return Response.json({ error: "Not enough gems" }, { status: 402 });
  }
  if (!rcRes.ok) {
    return Response.json({ error: "Spend failed" }, { status: 502 });
  }

  // The gems are gone, atomically. Grant the item, and refund if that fails.
  try {
    await grantItemToUser(appUserId, item);
  } catch (e) {
    // Compensate so we never charge for nothing.
    await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify({ adjustments: { GEM: cost } }),
    });
    return Response.json({ error: "Could not grant item, gems refunded" }, { status: 500 });
  }
  return Response.json({ ok: true });
}
422 を事前チェックではなくガードとして使ってください。 先に残高を読み取って比較したくなるかもしれません。 しかし、それには頼らないでください。読み取りと書き込みの間に残高は変わりえます(古典的な time-of-check vs time-of-use の競合状態です)。 差し引きはアトミックで、その結果を正とできるので、まず実行してみて、422 を「残高不足」として扱ってください。 GET .../virtual_currencies の読み取りは、残高の表示だけに使ってください。

アプリから呼び出して 402 に対応する

ここでの 402("Payment Required")は、バックエンドが自身のクライアントに返すアプリケーション独自の取り決めです。 RevenueCat の 422 とは別物で、「RevenueCat が残高不足と言っている」ことを、アプリが理解できるステータスに翻訳しているだけです。

swift
// GemStoreModel.swift (continued)
struct SpendRequest: Encodable {
    let item: String
    let idempotencyKey: String
}

enum SpendError: Error { case insufficientGems, failed }

extension GemStoreModel {
    // Create `idempotencyKey` once when the user taps buy, and reuse the SAME key on
    // every retry of this spend. A fresh UUID per attempt gives no double-spend safety.
    func spend(on item: String, idempotencyKey: String) async {
        do {
            var req = URLRequest(url: URL(string: "https://your-api.example.com/spend")!)
            req.httpMethod = "POST"
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            req.setValue("Bearer \(yourSessionToken)", forHTTPHeaderField: "Authorization")
            // The server looks up the price from the item; the client never sends a cost.
            req.httpBody = try JSONEncoder().encode(
                SpendRequest(item: item, idempotencyKey: idempotencyKey)
            )

            let (_, response) = try await URLSession.shared.data(for: req)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 500

            if status == 402 { throw SpendError.insufficientGems }
            guard (200..<300).contains(status) else { throw SpendError.failed }

            await refreshBalance()
        } catch SpendError.insufficientGems {
            errorMessage = "You need more gems for that."
        } catch {
            errorMessage = "Could not complete that purchase."
        }
    }
}
kotlin
// Add to GemStoreViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

private val http = OkHttpClient()

// Create idempotencyKey once per spend intent and reuse it on every retry.
fun spend(item: String, idempotencyKey: String) = viewModelScope.launch {
    try {
        val ok = withContext(Dispatchers.IO) {
            val body = JSONObject()
                .put("item", item) // server looks up the price; client never sends a cost
                .put("idempotencyKey", idempotencyKey)
                .toString()
                .toRequestBody("application/json".toMediaType())
            val req = Request.Builder()
                .url("https://your-api.example.com/spend")
                .header("Authorization", "Bearer $yourSessionToken") // app token, NOT the RC key
                .post(body)
                .build()
            http.newCall(req).execute().use { res ->
                if (res.code == 402) return@withContext false
                if (!res.isSuccessful) throw IllegalStateException("Spend failed")
                true
            }
        }
        if (!ok) {
            _state.update { it.copy(error = "You need more gems for that.") }
            return@launch
        }
        refreshBalance()
    } catch (e: Exception) {
        _state.update { it.copy(error = "Could not complete that purchase.") }
    }
}
dart
// Add to _GemStorePageState
import 'dart:convert';
import 'package:http/http.dart' as http;

// Create idempotencyKey once per spend intent and reuse it on every retry.
Future<void> spend(String item, String idempotencyKey) async {
  try {
    final res = await http.post(
      Uri.parse('https://your-api.example.com/spend'),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $yourSessionToken', // app token, NOT the RC key
      },
      body: jsonEncode({'item': item, 'idempotencyKey': idempotencyKey}),
    );
    if (res.statusCode == 402) {
      // show "You need more gems for that."
      return;
    }
    if (res.statusCode ~/ 100 != 2) throw Exception('Spend failed');
    await refreshBalance();
  } catch (e) {
    // show "Could not complete that purchase."
  }
}
typescript
// Create idempotencyKey once per spend intent and reuse it on every retry.
export async function spend(
  item: string,
  idempotencyKey: string,
  yourSessionToken: string,
  setGemBalance: (n: number) => void,
) {
  try {
    const res = await fetch('https://your-api.example.com/spend', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${yourSessionToken}`, // app token, NOT the RC key
      },
      body: JSON.stringify({ item, idempotencyKey }),
    });
    if (res.status === 402) {
      // show "You need more gems for that."
      return;
    }
    if (!res.ok) throw new Error('Spend failed');
    await refreshBalance(setGemBalance);
  } catch {
    // show "Could not complete that purchase."
  }
}
kotlin
// commonMain (Ktor client)
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class SpendRequest(val item: String, val idempotencyKey: String)

// Create idempotencyKey once per spend intent and reuse it on every retry.
suspend fun spend(
    client: HttpClient,
    item: String,
    idempotencyKey: String,
    yourSessionToken: String,
): Boolean {
    val res: HttpResponse = client.post("https://your-api.example.com/spend") {
        header(HttpHeaders.Authorization, "Bearer $yourSessionToken") // app token, NOT the RC key
        contentType(ContentType.Application.Json)
        setBody(Json.encodeToString(SpendRequest(item, idempotencyKey)))
    }
    return when {
        res.status.value == 402 -> false
        res.status.isSuccess() -> { refreshBalance(); true }
        else -> throw IllegalStateException("Spend failed")
    }
}
差し引きは成功したのに、アイテムの付与が失敗したら? 上のバックエンドは、付与が例外を投げた場合にジェムを返金します。 アイテムが自前のデータベースにある場合は、これで十分です。しかし、差し引きと報酬が別々のシステムにある場合(古典的な 「ジェムを自前サーバーのソフトカレンシーに交換する」ケース)は、その一発の返金では足りません。冪等キーと、補償(saga)パターン、 そして突き合わせジョブが必要になります。それこそが、このシリーズの次のコードラボで扱うテーマです。

エンドツーエンドでテストする

購入して残高が増えるのを確認する

  1. ストアのテスト環境でアプリを実行します(iOS: Xcode の StoreKit 構成ファイル、またはサンドボックステスター。Android: internal testing 上のライセンステスター)。
  2. サインインして安定した App User ID を確保し、ジェムストアを開きます。
  3. gems_1200 を購入します。付与が反映されると、残高が 1200 増えるはずです。加算は購入完了の直後に適用されるため、buy() のポーリングがそれを待ちます。
  4. ダッシュボードで付与を確認します。Customers を開いて App User ID を見つけ、仮想通貨の残高と取引履歴を確認します。

消費して残高不足の経路を試す

  1. アイテムにジェムを消費し、残高が減るのを確認します。
  2. 保有量を超える消費を試します。バックエンドは RevenueCat から 422 を受け取り、アプリに 402 を返し、アプリは残高を変えずに「You need more gems」と表示するはずです。
必要なら、付与を分析基盤にミラーリングできます。 購入による付与は VIRTUAL_CURRENCY_TRANSACTION Webhook(source: in_app_purchase 付き)を発行します。これは残高の読み取り専用のコピーをデータウェアハウスに同期するのに便利です。 信頼できる情報源は RevenueCat のままにしてください。この Webhook はレポート用であって、書き込む2つ目の残高のためのものではありません。

まとめと次のステップ

構築したもの

GEM 仮想通貨を作成し、検証済みの購入で残高に自動加算される消費型のジェムパックを販売し、 プラットフォーム上で SDK からリアルタイムの残高を表示し、Secret API キーを使ってバックエンドから安全にジェムを消費しました。 残高不足を判定する信頼できるガードには、RevenueCat のアトミックな 422 を使いました。 自前の UI が、RevenueCat の決済エンジンと残高の上に載っている構成です。

信頼できる唯一の情報源のチェックリスト

  • 各通貨には、信頼できる情報源が1つだけある。ここでは、ジェムを最初から最後まで RevenueCat が管理する。
  • アプリは SDK から残高を読み取るだけで、直接書き込まない
  • 付与は購入時に自動で行われ、消費はシークレットキーを使ってバックエンド経由で行う。
  • 同じ App User ID が、クライアント、残高、バックエンド呼び出しを結び付ける。
  • 自前のサーバーでソフトカレンシーも運用しているなら、それはそのまま残す。1つの通貨を2つのシステムにミラーリングしない。

さらに進む