Google Play Billing は、Android におけるアプリ内購入やサブスクリプションを処理するための包括的な API を提供しています。ほとんどの開発者は、標準的な購入フローには慣れているでしょう。すなわち、課金フローを起動し、結果を受け取り、購入を承認し、エンタイトルメントを付与するという流れです。しかし、本番環境の課金システムでは、チュートリアルやサンプルコードでは十分に扱われない、より幅広いシナリオに対応する必要があります。保留中の購入(Pending purchases)、複数量の消耗型アイテム、日割り計算を伴うサブスクリプションのダウングレード、そして ITEM_ALREADY_OWNED レスポンスは、いずれも実際の環境で発生するケースであり、これらを誤って処理すると、収益の損失、ユーザーの混乱、購入失敗につながる可能性があります。

本記事では、Google Play Billing における代表的なエッジケースを取り上げ、それぞれが発生する理由を理解し、Play Billing Library を使って正しく処理する方法を確認します。また、RevenueCat がこれらのシナリオをどのように簡素化し、課金インフラではなくプロダクト開発に集中できるようにするのかも見ていきます。

根本的な問題:ハッピーパスだけでは不十分

多くの課金実装は、Android の公式ドキュメントにあるサンプルコードから始まります。

\/\/ The happy path
billingClient.launchBillingFlow(activity, params)

\/\/ In PurchasesUpdatedListener
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        purchases?.forEach { purchase ->
            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                acknowledgePurchase(purchase)
                grantEntitlement(purchase)
            }
        }
    }
}

これは、成功した即時購入を処理するケースを想定しています。しかし、ユーザーがコンビニで支払いを行い、支払いが48時間遅れる場合はどうでしょうか? 以前の承認処理が静かに失敗しており、ユーザーがすでにその商品を所有している場合はどうでしょうか? サブスクリプションのダウングレードが即時ではなく、次回の更新時に反映される場合はどうでしょうか? これらの各シナリオには個別の対応が必要であり、無視するとサポート問い合わせの増加、返金リクエスト、そしてサブスクライバーの離脱につながります。

保留中の購入(Pending purchases):支払いが即時ではない場合

すべての購入が即座に完了するわけではありません。コンビニでの現金支払い、銀行振込、一部のキャリア決済など、特定の支払い方法では非同期処理が必要になります。ユーザーがこれらの方法で購入を開始すると、Google Play は PURCHASED 状態ではなく、PENDING 状態の購入として返します。

なぜ保留中の購入が発生するのか

保留中の購入は、クレジットカードの普及率が低い市場で一般的です。

支払い方法

一般的な地域

通常の処理時間

現金支払い(コンビニ)

日本、メキシコ、インドネシア

24〜48時間

銀行振込

ドイツ、オランダ、ブラジル

1〜3営業日

キャリア決済(一部のキャリア)

各国

数分〜数時間

アプリをグローバルに展開している場合、保留中の購入に遭遇するのは避けられません。この状態を無視すると、これらの地域のユーザーはそもそも商品を購入できなくなるか、あるいは購入が「消えた」ように見える混乱した挙動に直面することになります。

保留状態の検出と処理

PurchasesUpdatedListener は、完了した購入と同様に、保留中の購入も受け取ります。重要な違いは、 purchaseState フィールドにあります。

override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        purchases?.forEach { purchase ->
            when (purchase.purchaseState) {
                Purchase.PurchaseState.PURCHASED -> {
                    \/\/ Payment complete, grant access
                    processPurchase(purchase)
                }
                Purchase.PurchaseState.PENDING -> {
                    \/\/ Payment not yet complete
                    handlePendingPurchase(purchase)
                }
                Purchase.PurchaseState.UNSPECIFIED_STATE -> {
                    \/\/ Unknown state, query backend for clarification
                    queryBackendForState(purchase)
                }
            }
        }
    }
}

重要なルールは次のとおりです。保留中の購入に対してエンタイトルメントを付与してはいけません。ユーザーはまだ支払いを完了していません。代わりに、保留中の購入を記録し、そのステータスを明確にユーザーへ伝える必要があります。

fun handlePendingPurchase(purchase: Purchase) {
    \/\/ Store the pending purchase token for later verification
    purchaseRepository.savePendingPurchase(
        purchaseToken = purchase.purchaseToken,
        productId = purchase.products.first(),
        orderId = purchase.orderId,
        purchaseTime = purchase.purchaseTime,
    )

    \/\/ Show clear UI to the user
    showPendingUI(
        message = "Your purchase is being processed. " +
            "You'll get access once payment is confirmed.",
    )
}

保留中の購入の完了

支払いが最終的に確認されると、アプリは onPurchasesUpdated または queryPurchasesAsync を通じて更新された購入情報を受け取ります。purchaseState は PURCHASED になり、承認処理およびエンタイトルメントの付与を進めることができます。

ただし、注意点があります。支払いが完了した時点で、ユーザーがアプリを開いていない可能性があります。このケースには、Real-Time Developer Notifications(RTDN)を通じてバックエンドで対応すべきです。以前は保留中だったトークンに対して ONE_TIME_PRODUCT_PURCHASED または SUBSCRIPTION_PURCHASED の通知を受け取った場合、バックエンドでエンタイトルメントを更新し、ユーザーに通知する必要があります。

\/\/ Backend notification handler
fun handlePurchaseNotification(notification: DeveloperNotification) {
    val purchaseToken = notification.oneTimeProductNotification?.purchaseToken
        ?: notification.subscriptionNotification?.purchaseToken
        ?: return

    val pendingPurchase = purchaseRepository.findPendingPurchase(purchaseToken)
    if (pendingPurchase != null) {
        \/\/ Previously pending purchase is now complete
        val purchaseDetails = playDeveloperApi
            .purchases()
            .products()
            .get(packageName, pendingPurchase.productId, purchaseToken)
            .execute()

        if (purchaseDetails.purchaseState == 0) { \/\/ 0 = Purchased
            entitlementRepository.grantEntitlement(
                userId = pendingPurchase.userId,
                productId = pendingPurchase.productId,
            )
            purchaseRepository.markCompleted(purchaseToken)

            \/\/ Notify user that their purchase is ready
            notificationService.sendPushNotification(
                userId = pendingPurchase.userId,
                title = "Purchase Complete",
                body = "Your purchase has been confirmed. Enjoy your content!",
            )
        }
    }
}

BillingClient で保留中の購入を有効にする

保留中の購入をサポートするには、 BillingClient を構築する際に明示的に有効化する必要があります。これを行わない場合、遅延支払い方法による購入は完全に失敗します。

val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(
        PendingPurchasesParams.newBuilder()
            .enableOneTimeProducts()
            .enablePrepaidPlans()
            .build()
    )
    .build()

Play Billing Library 7 以降では、 enablePendingPurchases() の呼び出しが必須となっています。これを行わない場合、 BillingClient の初期化は失敗します。

ITEM_ALREADY_OWNED レスポンス:よくある混乱の原因

最も頻繁に遭遇するエッジケースの一つが BillingResponseCode.ITEM_ALREADY_OWNED です。このレスポンスは、ユーザーがすでに所有している非消耗型商品やサブスクリプションを再度購入しようとした場合に発生します。一見すると分かりやすい挙動に思えますが、実際にこれが発生するシナリオは意外なものが少なくありません。

なぜ ITEM_ALREADY_OWNED が発生するのか

最も一般的な原因は、ユーザーが意図的に同じ商品を二重購入しようとしていることではありません。問題は、以前の購入が正しく承認(acknowledge)されなかったことにあります。Google Play では購入の承認が必須であるため、未承認の購入は宙ぶらりんの状態に置かれます。つまり、ユーザーはすでに課金されているものの、購入がアプリ側で確認されていない状態です。このまま同じ商品を再度購入しようとすると、未承認の購入が残っているため、Google Play は ITEM_ALREADY_OWNED を返します。

この状況は、想像以上によく発生します。

  • アプリが購入受信後、承認前にクラッシュした
  • ネットワークエラーにより承認呼び出しが完了しなかった
  • 購入フロー中にユーザーがアプリを強制終了した
  • 承認 API 呼び出しがエラーを返し、再試行されなかった

ITEM_ALREADY_OWNED を正しく処理する

ITEM_ALREADY_OWNED に対する正しい対応は、エラーメッセージを表示することではありません。代わりに、既存の購入をクエリし、未承認の購入があればそれを処理するべきです。

override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    when (billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            purchases?.forEach { processPurchase(it) }
        }
        BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
            \/\/ Query existing purchases and process any unacknowledged ones
            recoverUnacknowledgedPurchases()
        }
        BillingClient.BillingResponseCode.USER_CANCELED -> {
            \/\/ User backed out, no action needed
        }
        else -> {
            handleBillingError(billingResult)
        }
    }
}

private fun recoverUnacknowledgedPurchases() {
    val params = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.forEach { purchase ->
                if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !purchase.isAcknowledged
                ) {
                    \/\/ Found the unacknowledged purchase, process it
                    processPurchase(purchase)
                }
            }
        }
    }
}

このパターンにより、フラストレーションを招くエラーを、シームレスな復旧処理へと変えることができます。以前の購入が承認に失敗していたことを、ユーザーが知る必要はありません。ユーザーの視点では、「購入」をタップすれば商品が手に入るだけです。

ITEM_ALREADY_OWNED を事前に防ぐ

最善のアプローチは、アプリ起動時に未承認の購入を処理することで、この状況を未然に防ぐことです。

fun processUnacknowledgedPurchasesOnStartup() {
    val inAppParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    val subsParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()

    billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.filter {
                it.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !it.isAcknowledged
            }.forEach { processPurchase(it) }
        }
    }

    billingClient.queryPurchasesAsync(subsParams) { result, purchases ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.filter {
                it.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !it.isAcknowledged
            }.forEach { processPurchase(it) }
        }
    }

このメソッドは、 BillingClient が正常に接続されたタイミングで呼び出してください。これにより、取りこぼされていた購入がユーザーに問題が発生する前に回復されます。

消耗型購入: acknowledge と consume の違い

ゲーム内通貨、追加ライフ、トークンパックのような消耗型商品では、承認(acknowledgment)と消費(consumption)の違いがバグの一般的な原因になります。どちらも消耗型商品には必要ですが、目的もタイミング要件も異なります。

承認と消費のフロー

承認(Acknowledgment)は、購入されたコンテンツを配信済みであることを Google Play に通知するものです。購入から3日以内に行う必要があり、期限を過ぎると自動的に返金されます。

消費(Consumption)は、その購入をリセットし、ユーザーが同じ商品を再度購入できるようにする処理です。商品を消費しない場合、ユーザーは再購入できず、再購入を試みると ITEM_ALREADY_OWNED が返されます。

消耗型商品の場合は、購入を消費するべきです。消費処理は暗黙的に承認も行います。

fun processConsumablePurchase(purchase: Purchase) {
    \/\/ Verify with backend first
    verifyPurchaseWithBackend(purchase) { isValid ->
        if (isValid) {
            \/\/ Grant the consumable content
            grantConsumableContent(purchase)

            \/\/ Consume the purchase (this also acknowledges it)
            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                    \/\/ Retry consumption, the user cannot rebuy until consumed
                    scheduleConsumptionRetry(purchase.purchaseToken)
                }
            }
        }
    }
}

複数量購入のエッジケース

Google Play は、消耗型商品の複数量購入をサポートしています。ユーザーは、1回のトランザクションで同じ消耗型商品を複数個購入できます。数量は Purchase オブジェクト内で取得できます。

fun processConsumablePurchase(purchase: Purchase) {
    val quantity = purchase.quantity  \/\/ Could be > 1

    verifyPurchaseWithBackend(purchase) { isValid ->
        if (isValid) {
            \/\/ Grant the correct quantity
            grantConsumableContent(purchase.products.first(), quantity)

            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                    scheduleConsumptionRetry(purchase.purchaseToken)
                }
            }
        }
    }
}

quantity フィールドを無視して常に1ユニットしか付与しない場合、複数個を購入したユーザーは支払った分より少ないアイテムしか受け取れません。これはサポート問い合わせや返金リクエストの原因になります。

複数量購入を有効にするには、Google Play Console で該当商品に対して「Allow multi-quantity purchases(複数量購入を許可)」を有効にする必要があります。さらに、 BillingFlowParams ではユーザーが選択できる最大数量を指定できます。

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(
        listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .build()
        )
    )
    .build()

消費処理(consume)の再試行問題

たとえばネットワークエラーなどで consumeAsync の呼び出しが失敗すると、ユーザーはコンテンツを受け取っているのに、購入が消費されていない状態になります。これは次のような問題につながります。

  1. ユーザーが同じ消耗型商品を再度購入できない
  2. 承認されないまま3日が経過すると購入が返金される可能性がある(消費処理は暗黙的に承認も行いますが)

そのため、消費処理に失敗した場合に備えて、再試行メカニズムを実装するべきです。

class ConsumptionRetryManager(
    private val billingClient: BillingClient,
    private val purchaseRepository: PurchaseRepository,
) {
    fun scheduleConsumptionRetry(purchaseToken: String) {
        purchaseRepository.markPendingConsumption(purchaseToken)
    }

    fun retryPendingConsumptions() {
        val pendingTokens = purchaseRepository.getPendingConsumptionTokens()

        pendingTokens.forEach { token ->
            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(token)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    purchaseRepository.clearPendingConsumption(token)
                }
                \/\/ If still failing, it will be retried next time
            }
        }
    }
}

BillingClient が接続されるたびに、未承認購入のリカバリーロジックとあわせて retryPendingConsumptions() を呼び出してください。

サブスクリプションのダウングレードと proration日割り計算)モード

ユーザーがサブスクリプションプランを変更する際の課金挙動は、アップグレードかダウングレードか、そしてどの proration モードを指定しているかによって異なります。特にダウングレードは、多くの開発者にとって予想外の挙動を示します。

デフォルトのダウングレード挙動

ユーザーがサブスクリプションをダウングレード(より安価なプランへ変更)した場合、デフォルトの挙動は DEFERRED です。つまり、ダウングレードは即時ではなく、次回の更新日に適用されます。現在の請求期間が終了するまでは、ユーザーは引き続き上位プランの機能を利用できます。

fun launchDowngrade(
    activity: Activity,
    newProductDetails: ProductDetails,
    newOfferToken: String,
    currentPurchaseToken: String,
) {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            listOf(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                    .setProductDetails(newProductDetails)
                    .setOfferToken(newOfferToken)
                    .build()
            )
        )
        .setSubscriptionUpdateParams(
            BillingFlowParams.SubscriptionUpdateParams.newBuilder()
                .setOldPurchaseToken(currentPurchaseToken)
                .setSubscriptionReplacementMode(
                    BillingFlowParams.SubscriptionUpdateParams
                        .ReplacementMode.DEFERRED
                )
                .build()
        )
        .build()

    billingClient.launchBillingFlow(activity, billingFlowParams)
}

リプレースメントモードの理解

各リプレースメントモードは、課金、アクセス権、そしてユーザー体験にそれぞれ異なる影響を与えます。

モード

変更が適用されるタイミング

課金への影響

最適な用途

IMMEDIATE_WITH_TIME_PRORATION

即時

残り期間分が新しいプランにクレジットされる

ユーザーが即時にアクセスできるアップグレード

IMMEDIATE_AND_CHARGE_PRORATED_PRICE

即時

残り期間分に対して日割り請求

公平な課金を伴うアップグレード

IMMEDIATE_AND_CHARGE_FULL_PRICE

即時

新しいプランの全額が請求され、新しい請求期間が開始

プレミアムへのアップグレード

DEFERRED

次回更新時

即時の請求なし

ダウングレード

IMMEDIATE_WITHOUT_PRORATION

即時

次回更新まで追加請求なし

同価格帯プラン間の移動や上位プランのトライアル

遅延ダウングレード(Deferred)の落とし穴

遅延ダウングレードで最もよくある誤りは、購入フロー完了直後にサブスクリプション状態を確認し、新しいプランが反映されていることを期待してしまうことです。 DEFERRED モードでは、次回の更新日まで元のサブスクリプションが元のプロダクト ID のまま有効です。新しいサブスクリプションが表示されるのは更新後になります。

つまり、エンタイトルメントのチェックでは、この移行期間を考慮する必要があります。

fun handleDowngradeResult(purchase: Purchase) {
    \/\/ After a deferred downgrade, the purchase still reflects
    \/\/ the OLD subscription until the next renewal
    val currentProductId = purchase.products.first()

    \/\/ Query the subscription status from your backend to check
    \/\/ if a deferred downgrade is pending
    checkBackendForPendingDowngrade(purchase.purchaseToken) { pendingDowngrade ->
        if (pendingDowngrade != null) {
            \/\/ Show UI indicating the downgrade is scheduled
            showDowngradeScheduledUI(
                currentPlan = currentProductId,
                futurePlan = pendingDowngrade.newProductId,
                effectiveDate = pendingDowngrade.effectiveDate,
            )
        } else {
            \/\/ Normal subscription state
            showSubscriptionUI(currentProductId)
        }
    }
}

プラン変更時の linkedPurchaseToken

サブスクリプションのリプレースメント(アップグレードまたはダウングレード)が処理されると、新しい購入トークンが生成されます。この新しい購入には、以前のサブスクリプションを参照する linkedPurchaseToken フィールドが含まれます。重複したエンタイトルメントを作成しないよう、バックエンドでこれを正しく処理する必要があります。

\/\/ Backend handler for subscription replacement
fun handleSubscriptionReplacement(newPurchaseToken: String) {
    val subscription = playDeveloperApi
        .purchases()
        .subscriptionsv2()
        .get(packageName, newPurchaseToken)
        .execute()

    val linkedToken = subscription.linkedPurchaseToken

    if (linkedToken != null) {
        \/\/ This is a plan change, not a new purchase
        val userId = userRepository.findByPurchaseToken(linkedToken)

        \/\/ Update to new token
        userRepository.updatePurchaseToken(userId, newPurchaseToken)

        \/\/ Invalidate old token to prevent double-counting
        subscriptionRepository.invalidate(linkedToken)
    }

    \/\/ Acknowledge the new purchase
    acknowledgePurchase(newPurchaseToken)
}

リプレースメント処理時に古い購入トークンを無効化しないことは、よくあるバグの一つであり、サブスクライバー数の水増しや誤った収益レポートにつながります。

ネットワーク障害と再試行戦略

課金処理はネットワークに依存しており、ネットワーク障害や遅延は避けられません。失敗する可能性がある重要な処理には、購入フローそのもの、承認(acknowledgment)、消費(consumption)、購入検証が含まれます。

承認の猶予期間

Google Play では、購入を承認するために3日間の猶予が与えられています。この期間内に承認しない場合、購入は自動的に返金されます。これはユーザー保護のための仕組みですが、その分、承認ロジックは一時的な障害に対して耐性を持つ必要があります。

class AcknowledgmentManager(
    private val billingClient: BillingClient,
    private val purchaseRepository: PurchaseRepository,
) {
    fun acknowledgePurchaseWithRetry(purchase: Purchase) {
        if (purchase.isAcknowledged) return

        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()

        billingClient.acknowledgePurchase(params) { billingResult ->
            when (billingResult.responseCode) {
                BillingClient.BillingResponseCode.OK -> {
                    purchaseRepository.markAcknowledged(purchase.purchaseToken)
                }
                BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
                BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
                BillingClient.BillingResponseCode.ERROR -> {
                    \/\/ Transient failure, schedule retry
                    purchaseRepository.markPendingAcknowledgment(
                        purchase.purchaseToken
                    )
                }
                else -> {
                    \/\/ Non-retryable error, log for investigation
                    logAcknowledgmentFailure(purchase, billingResult)
                }
            }
        }
    }

    fun retryPendingAcknowledgments() {
        val pendingTokens = purchaseRepository.getPendingAcknowledgmentTokens()

        val inAppParams = QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.INAPP)
            .build()

        billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
            if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                purchases
                    .filter { it.purchaseToken in pendingTokens }
                    .filter { !it.isAcknowledged }
                    .forEach { acknowledgePurchaseWithRetry(it) }
            }
        }
    }
}

BillingClient の切断

The BillingClient はいつでも切断される可能性があり、切断された状態で実行された操作は失敗します。そのため、指数バックオフを用いた再接続ロジックを実装する必要があります。

class BillingClientManager(
    private val context: Context,
    private val listener: PurchasesUpdatedListener,
) {
    private var billingClient: BillingClient? = null
    private var retryCount = 0

    fun connect(onConnected: () -> Unit) {
        billingClient = BillingClient.newBuilder(context)
            .setListener(listener)
            .enablePendingPurchases(
                PendingPurchasesParams.newBuilder()
                    .enableOneTimeProducts()
                    .build()
            )
            .build()

        billingClient?.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    retryCount = 0
                    onConnected()
                } else {
                    retryConnection(onConnected)
                }
            }

            override fun onBillingServiceDisconnected() {
                retryConnection(onConnected)
            }
        })
    }

    private fun retryConnection(onConnected: () -> Unit) {
        if (retryCount < MAX_RETRY_COUNT) {
            retryCount++
            val delayMs = (1000L * (1 shl retryCount)).coerceAtMost(MAX_RETRY_DELAY_MS)
            handler.postDelayed({ connect(onConnected) }, delayMs)
        }
    }

    companion object {
        private const val MAX_RETRY_COUNT = 5
        private const val MAX_RETRY_DELAY_MS = 30_000L
    }
}

RevenueCat がこれらのエッジケースをどう扱うか

これまでに説明した各エッジケースには、慎重な実装、再試行ロジック、そしてバックエンド基盤が必要です。ここで RevenueCat は、複雑さを抽象化し、これらのシナリオを自動的に処理することで大きな価値を提供します。

保留中の購入(Pending purchases)

RevenueCat は保留中の購入状態を内部で追跡し、支払いが確定したタイミングで CustomerInfo を更新します。アプリ側はエンタイトルメントを確認するだけで済みます。

Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val isPremium = customerInfo.entitlements["premium"]?.isActive == true

    if (isPremium) {
        showPremiumContent()
    } else {
        showSubscriptionOptions()
    }
}

RevenueCat のバックエンドは、Google Play からの RTDN 通知を処理します。そのため、保留中の購入が完了すると、エンタイトルメントは RevenueCat のサーバー側で更新されます。次にアプリが CustomerInfo を取得したときには、そのエンタイトルメントは有効になっています。こちら側で独自の通知処理や購入トークンの追跡を行う必要はありません。特に個人開発の場合、こうしたバックエンド基盤を一式構築するのは非常に大きなリソースが必要になります。

承認と消費

RevenueCat は承認(acknowledgment)と消費(consumption)を自動で処理します。SDK が購入を受け取ると、その購入は RevenueCat のバックエンドで検証され、RevenueCat があなたに代わって Google Play に対して承認を行います。消耗型商品については、検証後に RevenueCat が消費処理を行います。 acknowledgePurchase や consumeAsync を自分で呼び出す必要はありません。

これにより、承認失敗、消費処理の呼び忘れ、そして ITEM_ALREADY_OWNED 問題に関連する一連のバグをすべて排除できます。

サブスクリプションプランの変更

RevenueCat は、purchaseWith を通じてサブスクリプションのアップグレードおよびダウングレードのためのシンプルな API を提供しています。

Purchases.sharedInstance.purchaseWith(
    PurchaseParams.Builder(activity, newPackage)
        .oldProductId(currentProductId)
        .googleReplacementMode(GoogleReplacementMode.DEFERRED)
        .build(),
    onSuccess = { transaction, customerInfo ->
        \/\/ CustomerInfo reflects the new subscription state
        updateUI(customerInfo)
    },
    onError = { error, userCancelled ->
        if (!userCancelled) {
            showError(error)
        }
    }
)

RevenueCat は、linked purchase token の処理、エンタイトルメントの移行、そして遅延ダウングレードの追跡をバックエンドで処理します。アプリ側では、現在のエンタイトルメント状態を CustomerInfo で確認するだけで済みます。

ネットワーク耐性

RevenueCat の SDK には、すべてのネットワーク操作に対する組み込みの再試行ロジックが含まれており、 CustomerInfo をローカルにキャッシュしてオフラインでも参照できるようにしています。また、接続が回復した際にはバックエンドと自動的に同期されます。これにより、デバイスがオフラインの状態でもアプリはエンタイトルメントを確認できます。

\/\/ This works offline using cached CustomerInfo
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val isPremium = customerInfo.entitlements["premium"]?.isActive == true
    updateUI(isPremium)
}

SDK は古いデータと最新データを区別し、失敗した操作を指数バックオフで再試行し、最終的にエンタイトルメントがサーバー側の状態と整合することを保証します。

まとめ

本記事では、サンプルレベルの課金実装と本番運用レベルの実装を分ける、さまざまなエッジケースについて見てきました。

これらの各シナリオには、Play Billing Library を直接使って解決するための明確な方法があります。しかし、それらをすべて実装するための累積的な工数は決して小さくありません。クライアント側の処理、バックエンドでの RTDN 処理、再試行メカニズム、そして各状態をまたいだ慎重なステート管理が必要になります。こうしたインフラを自前で構築・維持せずにサブスクリプション機能を提供したいチームにとって、RevenueCat はこれらのエッジケースを自動的に処理し、複雑な管理を行う代わりに単一の CustomerInfo オブジェクトを確認するだけで済むようにしてくれます。

課金インフラを自前で構築する場合でも、RevenueCat を利用する場合でも、これらのエッジケースを理解することは不可欠です。テスト環境では動く課金システムと、多様な市場や支払い方法にまたがる数百万のユーザーに対して安定して動作する課金システムとの違いは、まさにここにあります。

それでは、Happy coding!

— Jaewoong