dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

iOSのサブスクリプション機能 プロモーションオファーを触ってみた

f:id:nakanishi-w:20201211093438p:plain

こんにちは! dely で iOS エンジニアをしている nancy です。

はじめに

この記事は「dely #2 Advent Calendar 2020」の15日目の記事です。

adventar.org

adventar.org

昨日はクラシルのフロントエンドを担当されている しらりん さんの「ウェブの未来を描く Project Fugu」という記事でした。

tech.dely.jp

ウェブとネイティブアプリとの操作性のギャップを埋めるプロジェクト、 Project Fugu について書かれています! ウェブの開発をされている方だけでなく、ネイティブアプリの開発をされている方にもオススメの記事なので、興味のある方は是非ご覧ください!


本日は WWDC 2019 で発表された iOS の自動更新サブスクリプション機能の一つ、 「プロモーションオファー」について書きたいと思います。

プロモーションオファーとは

プロモーションオファーとは、WWDC 2019 で発表された iOS の自動更新サブスクリプション機能の一つで

過去にサブスクリプションに登録していた、もしくは現在サブスクリプションに登録しているユーザに対し、 「1ヶ月無料」や「1ヶ月100円引き」といった値引きしたプランを提供できるような機能です。

お試しオファーとプロモーションオファーの違い

プロモーションオファーが実装されるより以前は「お試しオファー」という初めてサブスクリプションに登録するユーザ向けの機能を使用してユーザへサブスクリプションへの登録を行っていました。

どのサブスクリプションでもよく見る「初めて登録される方なら nヶ月無料!」みたいなやつですね。

このお試しオファーは新規サブスクリプション登録者の獲得には有用ですが、一度しか利用できないため、解約ユーザへの訴求、現在サブスクリプションに登録してくれているユーザへの訴求には使用できませんでした。

これに対し「プロモーションオファー」では、解約したユーザや現在サブスクリプションに登録してくれているユーザに対して再度お得なプランを訴求することができるため、一度解約してしまったユーザに再登録を促すことができたり、現在サブスクリプションに登録してくれているユーザの長期継続につながったりすることが期待できます。

下の画像にもありますが、iOS 12.2 以上のユーザのみ利用可能な機能であるため、導入する際は注意が必要です。

f:id:nakanishi-w:20201205215400p:plain
https://developer.apple.com/jp/app-store/subscriptions/#providing-subscription-offers

※オファーコードも記載されていますが、今回の記事ではこの部分には触れません。

実装のはなし

プロモーションオファーは以下のような流れで実装していきます

  • プロモーションオファーの作成
  • プラン/プロモーションオファーの詳細を取得
  • 署名を生成
  • 購入処理を実行
  • レシート検証
  • トランザクションを完了

プロモーションオファーの作成

実装に入っていく前にプロモーションオファーの準備を App Store Connect 上で行います。

秘密鍵の生成

まずはプロモーションオファーの課金に使用する秘密鍵の生成を行います。

こちらに記載の流れのように進めていきます

  1. 「ユーザとアクセス」→「キー」をクリックし、「サブスクリプション」を選択
  2. +ボタンをクリック
  3. 任意の名前を設定し、サブスクリプションキーを生成
  4. 生成した秘密鍵をダウンロード

※秘密鍵のダウンロードは1回しかできないため、ご注意ください。

プロモーションオファーの設定

秘密鍵が生成できたら、次にプロモーションオファーのプランを作成していきます。 プロモーションオファーは既存のプランに紐づく形で作成するため、元となるプランがまだ存在しない場合はそちらから作成するようにしてください。

プロモーションオファーの設定も Apple のドキュメント通りに進めていきます

  1. 「マイ App」から、設定する App を選択
  2. サイドバーの「App 内課金」で、「管理」をクリック
  3. 自動更新登録タイプのプロダクトをクリックし、「登録価格」セクションに移動して、「追加」ボタン(+)をクリック
  4. 「プロモーションオファーの作成」を選択 f:id:nakanishi-w:20201213171312p:plain
  5. 内部参照名とオファーコードを入力 f:id:nakanishi-w:20201213171342p:plain
  6. 「都度払い」、「前払い」、「無料」のいずれかを選択した後、適切な期間、通貨、価格を選択f:id:nakanishi-w:20201213171403p:plain

ちなみに、都度払いを選択した場合は「適用期間」「価格」を選択できるようになり、特定の期間だけ○円みたいな設定ができます。 f:id:nakanishi-w:20201213171421p:plain

以上でプロモーションオファーの作成は完了です。

以降は実装に入っていきます。

プラン/プロモーションオファーの詳細を取得

まずは以下のような処理でプランの詳細(SKProduct)のリクエストを行います。

var purchaseProductIdentifier: String?

func fetchProduct(id: String) {
    purchaseProductIdentifier = id
    let productIdentifiers = Set<[purchaseProductIdentifier!]>
    request = SKProductsRequest(productIdentifiers: productIdentifiers)
    request.delegate = self
    request.start()
}

次に SKPaymentQueueDelegate にて SKProduct の取得完了通知を受け取ります。この辺りは既に自動更新サブスクリプションを導入されている場合は同じ処理になるかと思います。

ただ、プロモーションオファーが紐づいているプランを取得した場合、 SKProductdiscounts: [SKProductDiscount] に紐づいているプロモーションオファーが全て入った状態で取得できます。

SKProductDiscount にはプランの料金や適用期間などが入っているので、 SKProduct の情報を使用して View を更新する場合は discounts を参照するようにしてください。

public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    guard let purchaseProductIdentifier = purchaseProductIdentifier,
          let product = response.products.first(where: { $0.productIdentifier ==  purchaseProductIdentifier }) else {
        // エラー処理
        return
    }
    // .discounts にプロモーションオファーの情報が入った状態で取得できる
    print(product.discounts)
    // 課金処理へ
}

署名を生成

参考:Generating a Signature for Promotional Offers

プロモーションオファーを導入するには既存の自動更新サブスクリプションとは異なり「署名の生成」を行い、得られた文字列を購入リクエストに含める必要があります。

署名の生成に必要な情報以下の通りです。

名称 説明
appBundleID アプリの Bundle Identifier
keyIdentifier App Store Connect で生成した秘密鍵を識別する ID
productIdentifier プランの ID
offerIdentifier プロモーションオファーの ID
applicationUsername サービス内でユーザを一意に識別する文字列(任意)
nonce サーバーサイドで生成する UUID(小文字)
timestamp サーバーサイドで生成する UNIX タイムスタンプ(ミリ秒)

node.js を用いて署名生成を行うサンプルコードを Apple が公開しているので、こちらを例に出しておきます。

一部変更を加えている部分もあるので、元コードを参照したい方は下記のリンクからご参照ください。

Generating a Subscription Offer Signature on the Server

router.get('/offer', function(req, res) {
    // App Bundle Identifier. ここではパラメータから取得しているが、環境変数として保持しても良さそう.
    const appBundleID = req.body.appBundleID;
    // プランの ID
    const productIdentifier = req.body.productIdentifier;
    // プロモーションオファーの ID
    const subscriptionOfferID = req.body.offerID;
    // ユーザを識別する文字列
    const applicationUsername = req.body.applicationUsername;

    // 環境変数等で保持している、秘密鍵の ID
    const keyID = 'xxxxx';
    // 秘密鍵の中身(こちらも本来は環境変数として持つべき)
    const keyString = '-----BEGIN PRIVATE KEY-----xxxxx-----END PRIVATE KEY-----';

    // UUID を生成.
    const nonce = uuidv4();
    
    // タイムスタンプを生成.
    const currentDate = new Date();
    const timestamp = currentDate.getTime();

    // 全ての文字列を不可視の分離文字列('\u2063')で結合
    const payload = appBundleID + '\u2063' +
                    keyID + '\u2063' +
                    productIdentifier + '\u2063' +
                    subscriptionOfferID + '\u2063' +
                    applicationUsername  + '\u2063'+
                    nonce + '\u2063' +
                    timestamp;

    // 秘密鍵を使用して楕円曲線デジタルアルゴリズム(ECDSA)オブジェクトを生成
    const key = new ECKey(keyString, 'pem');

    // SHA-256 署名アルゴリズムを使用するよう設定
    const cryptoSign = key.createSign('SHA256');

    // 結合した文字列を追加
    cryptoSign.update(payload);

    // 署名を生成し、base64 でエンコード.
    const signature = cryptoSign.sign('base64');
    
    // 生成した署名が正しいものなのか検証.
    // 署名の処理が正しく完了しているかを検証するもので、生成した署名で課金処理が正しく行えるかを検証するものではないので注意.
    // ex)timestamp を "ミリ秒" ではなく "秒" で作成していると課金時にエラーになるが、この部分での検証には成功する
    const verificationResult = key.createVerify('SHA256').update(payload).verify(signature, 'base64');
    console.log("Verification result: " + verificationResult)

    // アプリにレスポンスを返却.
    res.setHeader('Content-Type', 'application/json');
    res.json({ 'keyID': keyID, 'nonce': nonce, 'timestamp': timestamp, 'signature': signature });
});  

購入処理を実行

通常の自動更新サブスクリプションでは SKPayment を使用して購入リクエストを作成しますが、プロモーションオファーで課金する場合は SKMutablePayment を使用します。

また、課金するプロモーションオファーの ID などの情報は SKPaymentDiscout という型が用意されているので、こちらに必要な情報を入れ、 SKMutablePaymentpaymentDiscount プロパティにセットします

func purchase(product: SKProduct, username: String, offerIdentifier: String) {
    // サーバーに署名の生成をリクエスト
    YourServer.createSignature(username: username, productIdentifier: product.productIdentifier, offerIdentifier: offerIdentifier, completion: { (nonce: UUID, timestamp: NSNumber, keyIdentifier: String, signature: String) in
        // プロモーションオファーでは SKMutablePayment を使用
        let payment = SKMutablePayment(product: product)
        // プロモーションオファーの情報
        let discountOffer = SKPaymentDiscount(identifier: offerIdentifier, // プロモーションオファーの ID
                                              keyIdentifier: keyIdentifier, // 秘密鍵を識別する ID
                                              nonce: nonce, // サーバー側で生成する UUID
                                              signature: signature, // サーバー側で生成した署名文字列
                                              timestamp: timestamp) // サーバー側で生成したタイムスタンプ
        // SKMutablePayment に プロモーションオファーの情報を追加
        payment.paymentDiscount = discount
        // (任意)ユーザを識別する文字列を追加
        payment.applicationUsername = username

        SKPaymentQueue.default().add(payment)
    })
}

レシート検証 && トランザクションを完了

こちらは既存の自動更新サブスクリプションと変わらないため割愛します

実装していてハマったところ

過去課金経験がないとエラーになる

プロモーションオファーは過去に課金経験があること前提の機能なので、 新たに作成したテストアカウントで課金しようとすると当然エラーになります。

Apple からその旨のエラーが表示されるのでこの点で詰まったと言う訳ではないんですが、お試しされる際は過去に課金経験のあるアカウントで実施するようご注意ください。

f:id:nakanishi-w:20201213155822p:plain

ちなみに、iOS 14 から Sandbox アカウントで「利用資格のリセット」というものができるようになりましたが、過去課金経験のあるアカウントでこれを実行しても上記のエラーメッセージが表示されませんでした。

~identifier が多く混乱してくる

実装を進めている中で、~identifier と言う名称のものが多く、混乱してくる時がありました。

また、デバッグの際、プランの ID とプロモーションオファーの ID を似たものにしてしまっていたため、正しい値がセットされているのかを判断し難くなってしまっていたので、プロモーションオファーの ID には promotion_offer_~ のように接頭辞などを付けるようにすると分かりやすくなりそうでした。

署名生成時のデバッグがやりづらい

ここが最も詰まったポイントなんですが、署名生成のデバッグが辛かったです。

当然と言えば当然なんですが、署名生成時に改行文字列等の不要なものが含まれていたりすると決済に失敗してしまいます。

また、この際、ID/Password を入力する決済画面は問題なく表示され、ID/Password 入力後の決済時にエラーになると言う挙動になります。

そのため、当初は署名の生成自体は問題なく、アプリ側のロジックの不備を疑っていたので回り道をする結果となりました。署名生成時に検証を行っていましたが、あくまで正常に署名処理が完了できているかを確認するもので、Apple が定める条件通りに署名が行われているかを判定するものでは無かったのも落とし穴でした。

その他、気をつけたほうが良さそうなポイントを記載したので、参考にしていただければと思います。

項目 気をつけるところ
nonce の生成 アルファベットは必ず小文字である必要があるので注意
timestamp の生成 単位は "秒" ではなく "ミリ秒" なので注意
署名後の文字列 使用する言語やライブラリの仕様によっては勝手に "\n" を挿入したりすることがあるので注意

おわりに

いかがでしたでしょう?

プロモーションオファーを導入することで 今までアプローチできなかったユーザ層にアプローチすることが可能になるので、 既に自動更新サブスクリプションを実装されている場合は導入を検討してみると良いかもしれません!

明日、16日は弊社の Android エンジニアの meil さんによる「C# 9.0時代のnull判定解剖」です! お楽しみに!

また、dely では一緒にサービスを成長させていく仲間を募集中です!

www.wantedly.com

www.wantedly.com

定期的にイベントも開催しているので、dely のことを知りたいという方は是非是非ご参加お待ちしています!

bethesun.connpass.com