every Tech Blog

株式会社エブリーのTech Blogです。

Golangでアプリ課金(iab/iap)を実装するときは awa/go-iap が便利って話

この記事は every Tech Blog Advent Calendar 2024(夏) 28日目の記事です。

はじめに

DelishKitchenとヘルシカでバックエンドエンジニアをしているyoshikenです。

今回は新規アプリ開発の際に、Android/iOSアプリの課金処理(subscription)について awa/go-iapを使用して大変便利だったので、その紹介をしたいと思います。

awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS.

Android/iOSの課金処理のフローについてはサイバーエージェントさんのテックブログで詳しく説明されているので、そちらを参照してください。(この記事を書いてくださった方がそのまま awa/go-iapの原型を作成されたかと

自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ 自動購読課金について【iOS編】 | サイバーエージェント 公式エンジニアブログ

awa/go-iapとは

awa/go-iapは、In-App Purchase (IAP) のサポートを提供するライブラリで、実際にawaでも使用されているので一定の信頼性があり、更新頻度も高く、以前自分がissueを立てた際は次の日には修正されていました。

offerDiscountType is missing in JWSTransactionDecodedPayload · Issue #266 · awa/go-iap

また、今回はAndroid/iOSについての説明をしますが、他にもAmazon AppStoreやHuawei HMSといった決済方法に対応しています。

Android編

サーバー側でやることは

  1. 署名の検証
  2. receipt問い合わせ
  3. acknowledgeの送信

となります

署名の検証

サイバーエージェントさんのテックブログにも書かれていますが、送られてきたreceiptデータの検証に署名を利用します。

awa/go-iapを使用しない場合の実装はサイバーエージェントさんにお任せするとして…

awa/go-iapのVerifySignature関数を使用することで、簡単に署名の検証ができます。

https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#VerifySignature

以下に実装例を出します

import (
  "encoding/base64"
  "github.com/awa/go-iap/playstore"
  "yourapp/types"
)

// Verify receiptが改竄されていないかを検証
// receiptData: レシートデータ、Androidから送られてくるのはbase64化されている
// signature: レシートデータの署名
func Verify(receiptData, signature string) (bool, error) {
    // base64化されているので戻す
    b, err := base64.StdEncoding.DecodeString(receiptData)
    if err != nil {
        return false, err
    }

    // レシートデータ自体が正しいか署名を検証
    return playstore.VerifySignature(types.GooglePlayPublicKey, b, signature)
}

Androidから送られてくるreceiptデータがbase64化されているのでデコード処理が必要という意味で関数化しましたが、実際の処理は一行で終わるので無理に関数化しなくても良いかもしれません。

また、検証にはGooglePlayのPublicKeyが必要ですが、これはGooglePlayConsoleから取得できます。

subscription問い合わせ

receipt自体の検証が完了したら、購入情報を取得するために再度GooglePlayに問い合わせを行います。

こちらの問い合わせもVerifySubscriptionV2関数を使用することで簡単に実装できます。

https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#Client.VerifySubscriptionV2

import (
  "context"
  "github.com/awa/go-iap/playstore"
  "google.golang.org/api/androidpublisher/v3"
  "github.com/avast/retry-go/v4"

  "yourapp/types"

)

type PurchaseData struct {
    AutoRenewing     bool   `json:"autoRenewing"`
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int64  `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
}

func Verifyreceipt(ctx context.Context, receiptData, signature string) (*androidpublisher.SubscriptionPurchaseV2, error) {
   // 先程のVerify関数で返り値にdecode済みのreceiptデータを追加しても良いかもしれません
    b, err := base64.StdEncoding.DecodeString(receiptData)
    if err != nil {
        return nil, err
    }

    var purchaseData PurchaseData
    err = json.Unmarshal(b, &purchaseData)
    if err != nil {
        return nil, err
    }

    request, err := playstore.New([]byte(types.GooglePlayServiceAccount))
    if err != nil {
        return nil, err
    }

    var res *androidpublisher.SubscriptionPurchaseV2
   // ちょくちょく5xxを返すことがあるのでリトライ処理を入れています
    err = retry.Do(
        func() error {
            res = &androidpublisher.SubscriptionPurchaseV2{}
            res, err = request.VerifySubscriptionV2(ctx, purchaseData.PackageName, purchaseData.PurchaseToken)
            if err != nil {
                return err
            }
            return nil
        },
        retry.RetryIf(func(err error) bool {
            // 任意のエラーハンドリング
            if strings.Contains(err.Error(), "Service Unavailable") {
                return true
            }
        }),
    )
    if err != nil {
        return nil, err
    }
    return res, nil
}

acknowledgeの送信

署名の検証やらdb保存やら諸々が完了してレスポンスを返却するまえにGooglePlayに対してacknowledgeを送信して購入を承認する必要があります。

逆をいうと、acknowledgeを送信しないと購入が完了しないので、エラー時はacknowledgeさえ送信しないことに注意を払えばなんとかなります。

acknowledgeですが、こちらも関数が用意されており、 AcknowledgeSubscription を使用します。

import (
  "context"
  "github.com/awa/go-iap/playstore"
  "google.golang.org/api/androidpublisher/v3"
  "yourapp/types"
)

type PurchaseData struct {
    AutoRenewing     bool   `json:"autoRenewing"`
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int64  `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
}

func (u *UseCaseImpl) AckSub(ctx context.Context, purchaseReceipt PurchaseData) error {
    cl, err := playstore.New([]byte(types.GooglePlayServiceAccount))
    if err != nil {
        return err
    }

    return cl.AcknowledgeSubscription(ctx, purchaseReceipt.PackageName, purchaseReceipt.ProductID, purchaseReceipt.PurchaseToken, &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{})
}

acknowledgeには冪等性があるので何度再送しても大丈夫です。

iOS編

iOSもAndroidと似たようなものですが、serverから見るとAndroidより簡単です。

実際にApplestoreに問い合わせが必要な部分はreceiptの検証のみです。

以下が実装例です

import (
  "context"
  "github.com/awa/go-iap/appstore"
  "github.com/avast/retry-go/v4"
)

func VerifyReceipt(ctx context.Context, receiptData, password string) (*appstore.IAPResponse, error) {
    request := appstore.IAPRequest{
        ReceiptData: receiptData,
        Password:    password,
    }
    var res *appstore.IAPResponse
    err := retry.Do(
        func() error {
            res = &appstore.IAPResponse{}
            err := appstore.IAPClient.Verify(ctx, request, res)
            if err != nil {
                return err
            }
            err = appstore.HandleError(res.Status)
            if err != nil {
                return err
            }
            return nil
        },
        retry.RetryIf(func(err error) bool {
            // 任意のエラーハンドリング
              if strings.Contains(err.Error(), "Service Unavailable") {
                return true
            }
        }),
    )
    if err != nil {
        return res, err
    }
    return res, nil
}

GooglePlayと同じく時折5xxを返すのでリトライ処理を入れています。

iOSはクライアント購入時にack相当はしているのでサーバー側でのacknowledgeは不要です。

おわりに

以上のように、awa/go-iapを使用することで、Android/iOSの課金の購入処理を簡単に実装することができます

今回例に上げたのはsubscriptionのみですが、単発購入の場合も似たような実装になるかと思います。

ただ、subscriptionの場合は購入後の処理のほうが複雑で、定期的な購入情報を問い合わせるか、通知(App Store Server Notifications/RTDN)を受けるかしたあとにそれぞれのプロダクトに合わせた処理をしていきます。(今回は購入処理だけです

自分が実装するさいにawa/go-iapの参考になるドキュメントが少なかったため、今回の記事を書きました。誰かのお役に立てれば幸いです。

参考

awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS.

自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ

自動購読課金について【iOS編】 | サイバーエージェント 公式エンジニアブログ