GS2-Money2

課金通貨管理機能

ゲーム内のリソースのうち、現金相当の価値を持つリソースを扱う機能です。 日本の資金決済法の前払い式支払い手段(自家型)に該当する資産を取り扱う場合は必ずこの機能を利用してください。

GS2-Money2 は単に「課金通貨の残高」を扱うサービスではなく、以下のような会計・法令対応上の要件をまとめて引き受けるマイクロサービスです。

  • 入金日時・単価ごとの残高管理
  • レシート検証による不正入金の防止
  • ウォレットの加減算履歴の保持
  • プラットフォームのガイドラインに沿った通貨の隔離
  • サブスクリプション(期間課金)契約の状態管理
  • 未使用残高の集計(前払式支払手段への対応)

残高

GS2-Money2 はプレイヤーのもつ課金通貨の残高を単純な数量では管理せず、購入時の価値ごとに数量を管理します。

例えば、100円で100個の課金通貨を購入した場合は課金通貨1個の価値は1円相当となります。 同時に1000円で1200個の課金通貨を購入できるとしましょう。あくまで課金通貨の単価は1円とし、200個はおまけとして無料で処理するのも一つの手段ですし、1000円で購入した場合は単価を 0.8334円 として異なる単価で扱うのも一つの手段です。

後者のような方式を採用した場合、GS2-Money2 は「単価1円の課金通貨の残高」「単価0.8334円の課金通貨の残高」をそれぞれ分けて管理する機能を持っています。

後者を選択するメリットは?

明らかに後者は会計処理が複雑になり、メリットがないように感じるかもしれません。 サービス提供側としては全くのその通りです。しかし、立場を変えてゲームプレイヤーの立場で考えてみましょう。

ゲームの中には運営によって配られた現金相当の価値が0円の課金通貨(通称 無償通貨)があります。 プレイヤーに課金通貨を購入してもらうために、有償で購入した課金通貨(通称 有償通貨)でしか購入できない魅力的な商品を課金通貨300個で販売したとしましょう。

1000円 で 1200個の課金通貨を購入した際に、1000個の有償通貨と200個の無償通貨 を付与するように処理した場合 プレイヤーは有償通貨300個で購入できる商品を3回しか購入できません。 一方で、単価を0.8334円として1200個全てを有償通貨として取り扱う方法であればプレイヤーは4回購入できます。 この差はプレイヤー心理に多少なりとも影響を与えます。

会計上の都合を優先するか、プレイヤーの利益を優先するか 慎重に検討するべき仕様でしょう。

スロット

GS2-Money ではウォレットを複数持つことができます。 その複数のウォレットを区別するためのキーがスロットです。

この機能は他のプラットフォームで購入した課金通貨を持ち込ませないようにしているプラットフォーマーが存在するため、そのガイドラインを遵守するために存在する機能です。

しかし、これらのガイドラインは有償通貨にのみ適用されるため、無償通貨については全てのスロットで共有できる機能があります。 ネームスペースの sharedFreeCurrency を有効にすると、無償通貨は全スロット共通として扱われ、有償通貨のみがスロットごとに隔離されます。

graph LR
  subgraph Wallets[Wallets]
    direction TB
    S0["Slot 0 (iOS)<br/>有償残高: 1,000"]
    S1["Slot 1 (Android)<br/>有償残高: 500"]
    S2["Slot 2 (Steam)<br/>有償残高: 800"]
  end
  Shared["無償残高: 200<br/>(sharedFreeCurrency=true)"] -.共有.-> S0
  Shared -.共有.-> S1
  Shared -.共有.-> S2

購入通貨

GS2-Money2 では GS2-Money から変わった点として、1つのウォレットに複数の通貨で購入した課金通貨を保持できるようになりました。 たとえば、JPY で購入した有償通貨と、USD で購入した有償通貨を 1 つのウォレット内で別々の残高として保持できます。 1日数回の集計処理では通貨単位で DailyTransactionHistory が記録され、各通貨ごとの売上・消費を別個に把握できます。

消費優先度

プレイヤーが課金通貨を消費する時に、無償通貨を優先して消費するか、有償通貨を優先して消費するかを選択できます。 一般的に無償通貨を優先して消費する仕様が採用されますが、会計上の都合がある場合は有償通貨を優先できます。

有償通貨を消費する際には入金した時期が古いものから順番に消費されます。

ネームスペースの currencyUsagePriority で以下のいずれかを指定します。

設定値 説明
PrioritizeFree 無償通貨を優先的に消費(一般的)
PrioritizePaid 有償通貨を優先的に消費

レシートの検証

ゲームの配信プラットフォームで追加コンテンツの購入時に得られるレシートの検証機能を有しています。 レシートを検証し、正しくプラットフォーマーによって発行された内容であることを確認するとともに、過去にゲーム内で使用したことがないかも確認します。

レシートの検証には、ネームスペースの platformSetting に Apple App Store や Google Play の認証情報を登録しておく必要があります。

この機能を利用することで、不正なレシートを利用して課金通貨を入手しようとする攻撃を回避できます。

サポートしているプラットフォーム

プラットフォーム 設定キー 説明
Apple App Store appleAppStore iOS / iPadOS / macOS 向け課金
Google Play googlePlay Android 向け課金
Fake fake 開発・QA 用のダミーレシート発行

開発時には fake を利用してレシートを擬似的に発行することで、決済を実行せずにテストフローを回せます。

トランザクションログ

レシートの検証履歴はもちろんですが、課金通貨の加算・減算履歴も全て記録されます。 そして、毎日数回 現在ゲーム内には未使用の課金通貨が現金相当額でいくらプールされているかを集計します。

状況に応じて未使用残高の一部を第三者機関に供託する対応が必要になることがありますが、その時にこの計算結果を利用できます。

主なログ・履歴データ

データ 説明
DepositEvent 入金処理の履歴
WithdrawEvent 消費処理の履歴
VerifyReceiptEvent レシート検証の履歴
RefundEvent プラットフォームからの返金通知履歴
RefundHistory 返金の取り消し履歴
DailyTransactionHistory 1 日単位での通貨ごとの入出金集計
UnusedBalance 通貨ごとの未使用残高の現在値

法的手続き

GS2-Money2 は各種法的手続きをとるために必要なデータを収集し、APIによってアクセス可能な状態で保持しますが、 法的手続き自体はGS2の利用者である あなた/あなたが所属する組織 が実行する必要があります。

どのような手続きが必要となるかは、GS2では責任を持つことができないためアドバイスもできません。顧問弁護士に相談するようにしてください。

トランザクションアクション

GS2-Money2 では以下のトランザクションアクションを提供しています。

  • 消費アクション: 残高の消費、レシートの検証
  • 入手アクション: 残高の加算

「残高の加算」を入手アクションとして利用することで、特定のイベントクリア時や、ガチャの「おまけ」として直接課金通貨(有償・無償)を付与するといった処理を、トランザクション内で安全に実行できます。これにより、課金とゲーム内報酬を組み合わせた柔軟な施策が可能になります。

「レシートの検証」を消費アクションとして利用することで、レシートを使った購入処理から課金通貨を加算するまでを一連のトランザクションとして安全に処理できます。

期間課金(サブスクリプション)

GS2-Money2 では、都度課金の課金通貨だけでなく期間課金(サブスクリプション)を扱えます。 あらかじめ StoreSubscriptionContentModel のマスターデータを登録し、scheduleNamespaceId と triggerName を使って GS2-Schedule と連動させることで、契約期間の開始・更新・終了を自動的に反映できます。

graph LR
  Player["プレイヤー"] -->|購入| Store["App Store / Google Play"]
  Store -->|レシート| Player
  Player -->|AllocateSubscriptionStatus| Money2[GS2-Money2]
  Money2 -->|Trigger| Schedule[GS2-Schedule]
  Schedule -.期限管理.-> Money2
  Money2 -->|Notification| Player

契約情報の管理

各購入は SubscribeTransaction に記録され、トランザクションごとの契約詳細やストア情報を追跡できます。 プレイヤーごとの契約状態は SubscriptionStatus に保存され、現在のステータス (active / inactive) や expiresAt の確認が可能です。

AllocateSubscriptionStatus でレシートを使って契約状態をプレイヤーに紐付け、TakeOverSubscriptionStatus を使って別のプレイヤーに引き継ぐことも可能です。これは家族で契約を共有する場合や、子供アカウントから大人アカウントへ契約を引き継ぐような運用に利用できます。

ライフサイクルに応じたスクリプトトリガー

契約操作の前後にはスクリプトを実行できます。同期・非同期のいずれかを選択し、外部サービス連携にも対応します。

トリガー名 主な用途
subscribeScript 新規契約時(ユーザーの紐づけ変更時は除く)
renewScript 契約更新時
unsubscribeScript 解約時(ユーザーの紐づけ変更時は除く)
takeOverScript 契約のユーザー割り当てを変更する際

契約状況の通知

サブスクリプションの状態が変化した場合、changeSubscriptionStatusNotification に設定したプッシュ通知を送信できます。 プレイ中に期限切れや解約といった変化をハンドリングし、UIへ反映する用途に活用してください。

スクリプトトリガー

ネームスペースに depositBalanceScriptwithdrawBalanceScript verifyReceiptScript を設定すると、入出金処理の前後でカスタムスクリプトを実行できます。トリガーは同期・非同期の実行方式を選択でき、Amazon EventBridge を利用した外部連携にも対応します。

設定できる主なイベントトリガーとスクリプト設定名は以下の通りです。

  • depositBalanceScript(完了通知: depositBalanceDone): 入金処理の前後
  • withdrawBalanceScript(完了通知: withdrawBalanceDone): 出金処理の前後
  • verifyReceiptScript (完了通知: verifyReceiptDone): レシート検証の前後

同期スクリプトを使用して特定の条件に該当する場合に入出金処理を拒否したり、非同期スクリプトを利用して BI ツールへリアルタイムに売上を送信するといった運用が可能です。

プッシュ通知

設定できる主なプッシュ通知と設定名は以下の通りです。

  • changeSubscriptionStatusNotification: 期間課金の契約状況が変化したときに通知

マスターデータ運用

マスターデータを登録することでマイクロサービスで利用可能なデータや振る舞いを設定できます。

マスターデータの種類には以下があります。

  • StoreContentModel: 販売コンテンツ定義
  • StoreSubscriptionContentModel: 定期購読コンテンツ定義

マスターデータの登録はマネージメントコンソールから登録する他、GitHubからデータを反映したり、GS2-Deployを使ってCIから登録するようなワークフローを組むことが可能です。

StoreContentModel の例

販売コンテンツに対する App Store / Google Play の商品IDの対応関係を定義します。

{
  "version": "2022-07-13",
  "storeContentModels": [
    {
      "name": "stone_300",
      "metadata": "石300個パック",
      "appleAppStore": {
        "productId": "io.gs2.sample.stone_300"
      },
      "googlePlay": {
        "productId": "io.gs2.sample.stone_300"
      }
    }
  ]
}

バフによる補正

GS2-Buff と連携すると DepositByUserIdcountWithdrawWithdrawByUserIdwithdrawCount にバフを適用して入金量や消費量を一時的に増減できます。イベントやキャンペーンに応じて柔軟に調整できます。

たとえば、課金通貨ボーナスキャンペーンとして「期間中に購入した課金通貨を 10% 増量する」といった施策を、DepositByUserIdcount に対する Rate Add 0.1 のバフとして実装できます。

実装例

残高を取得

    var item = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Wallet(
        slot: 0
    ).ModelAsync();
    const auto Domain = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Wallet(
        0 // slot
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();

ウォレット一覧を取得

スロットごとのウォレットをまとめて取得することができます。 複数プラットフォームで課金通貨を分離している場合の残高表示に活用できます。

    var items = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).WalletsAsync(
    ).ToListAsync();
    const auto It = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Wallets();
    TArray<Gs2::UE5::Money2::Model::FEzWalletPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

残高を加算

残高を加算する処理はゲームエンジン用の SDK では処理できません。

GS2-Showcase の購入時の報酬として残高を加算するといった方法で実装してください。

残高を消費

このAPIで残高の消費処理を行うことは推奨していません。 GS2-Showcase といったサービスを通して課金通貨の消費を行う代わりに 何らかの処理を実行することを推奨します。

    var result = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Wallet(
        slot: 0
    ).WithdrawAsync(
        withdrawCount: 50,
        paidOnly: false
    );
    var item = await result.ModelAsync();
    const auto Future = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Wallet(
        0 // slot
    )->Withdraw(
        50,
        nullptr // paidOnly
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

サブスクリプション契約状態の取得

    var item = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).SubscriptionStatus(
        contentName: "premium-0001"
    ).ModelAsync();
    const auto Domain = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->SubscriptionStatus(
        "premium-0001" // contentName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();

サブスクリプション契約の割り当て

App Store / Google Play で取得したレシートを使って、プレイヤーにサブスクリプション契約を割り当てます。 レシートの検証も合わせて行われ、不正なレシートを使った契約は拒否されます。

    var domain = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).AllocateSubscriptionStatusAsync(
        receipt: receipt
    );
    const auto Future = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->AllocateSubscriptionStatus(
        Receipt
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

サブスクリプション契約の引き継ぎ

別のプレイヤーから現在のプレイヤーに対してサブスクリプション契約の紐付け先を変更します。 プラットフォーム側の契約自体は変更されず、ゲーム内での適用先のみが変更されます。

    var domain = await gs2.Money2.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).TakeOverSubscriptionStatusAsync(
        receipt: receipt
    );
    const auto Future = Gs2->Money2->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->TakeOverSubscriptionStatus(
        Receipt
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

詳細なリファレンス