MIXI DEVELOPERS
💎

AppStoreのプロモーションオファーを触ってみた

2024/12/23に公開

これは?

  • App Store上のアプリのサブスクリプション商品について期間限定の割引などができるプロモーションオファーの実装をしてみたのでやったことを下記する
  • プロモーションオファーはサブスクリプションオファーとも呼ばれていることもある

フロー

公式から抜粋
flow

App Store Connect上の設定

App Store Connect側でいくつかの設定が必要

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

サブスクリプション商品 -> サブスクリプション価格 -> プロモーションオファーのタブ -> プロモーションオファーを作成する

アプリ内課金キーを取得

ユーザとアクセス -> 統合 -> アプリ内課金でアプリ内課金キーを生成し、ダウンロード(p8ファイル)する。アプリ内課金のページでキーID(あとで使う)も確認できる。
https://developer.apple.com/jp/help/app-store-connect/configure-in-app-purchase-settings/generate-keys-for-in-app-purchases/

iOSアプリ側の実装

StoreKit1

  • SKPaymentDiscount でプロモーションオファーを利用できる。
  • 自分が担当するアプリではStoreKit2への移行をまだできておらずStoreKit1で試した。
/// サブスクリプション商品にプロモーションオファーを設定して購入する
///
/// - Parameters:
///     - product: サブスクリプション商品
///     - offerId: プロモーションオファーのID
func purchase(with product: SKProduct, offerId: String) {
    // 購入リクエストを作成
    let payment = SKMutablePayment(product: product)
    // 署名生成API(自前で実装する必要がある。次項参照)からプロモーションオファーの情報を取得
    let promotionOffer = try await getPromotionOffer(id: offerId, productIdentifier: product.productIdentifier)
    payment.applicationUsername = "ユーザーIDなどを指定"
    payment.paymentDiscount = SKPaymentDiscount(
        identifier: offerId,
        keyIdentifier: promotionOffer.keyId,
        nonce: UUID(uuidString: promotionOffer.nonce)!,
        signature: promotionOffer.signature,
        timestamp: promotionOffer.timestamp as NSNumber
    )
    // 購入処理開始
    SKPaymentQueue.default().add(payment)
}

StoreKit2

  • promotionalOfferでプロモーションオファーを反映できる。
  • 試せてないが多分StoreKit2だと下記のような感じだと思います🙇‍♂️
/// サブスクリプション商品にプロモーションオファーを設定して購入する
///
/// - Parameters:
///     - product: サブスクリプション商品
///     - offerId: プロモーションオファーのID
func purchase(product: Product, offerId: String) async {
        do {
            // 署名生成API(自前で実装する必要がある。次項参照)からプロモーションオファーの情報を取得
            let promotionOffer = try await getPromotionOffer(id: offerId, productIdentifier: product.productIdentifier)
            // プロモーションオファーの設定
            let offer = try await product.subscription?.promotionalOffer(
                offerID: offerId,
                keyIdentifier: promotionOffer.keyId,
                nonce: UUID(uuidString: promotionOffer.nonce)!,
                signature: promotionOffer.signature,
                timestamp: timestamp
            )
            
            guard let promotionalOffer = offer else {
                print("Failed to create promotional offer")
                return
            }
            
            // 購入処理
            let result = try await product.purchase(options: [.promotionalOffer(promotionalOffer), .appAccountToken(UUID(uuidString: "ユーザーIDなどを指定。storekit1のapplicationUsername相当")!])
            switch result {
            case .success(let verification):
                switch verification {
                case .verified(let transaction):
                    // 購入成功
                    await transaction.finish()
                    print("Purchase successful with promo offer: \(transaction.productID)")
                case .unverified(_, let error):
                    print("Verification failed: \(error)")
                }
            case .userCancelled:
                print("User cancelled the purchase")
            case .pending:
                print("Purchase is pending")
            @unknown default:
                print("Unknown purchase result")
            }
        } catch {
            print("Purchase with promo offer failed: \(error)")
        }
    }

Server側の実装(署名生成API)

  • プロモーションオファーの署名は自前のアプリケーションサーバーで実装する必要がある。

  • 公式ではnodejsの参考実装がある。

  • Swift, Java, Python, Nodeのライブラリも存在しているようです。

  • 自分の担当しているアプリのサーバーAPIはRuby on Railsだが、Rubyについては公式ライブラリも参考実装も無さそうだったので自分で頑張って実装してみる。

  • サーバー側の署名生成APIについてはStoreKit1/2どちらも多分同じ

class ApplePromotionOffersController < ApplicationController
  # GET /apple_promotion_offers/:id
  def show
    # プロモーションオファーのID
    offer_identifier = params[:id]
    # サブスクリプション商品のID
    product_identifier = params[:product_identifier]

    application_username = 'ユーザーIDなどを指定'

    # 必要な情報を環境変数やSecretsから取得
    team_id = 'チームID。AppleDeveloperで確認できる'
    key_id = 'キーID。アプリ内課金キーのページで確認できる'
    private_key_path = 'AppStoreConnectからダウンロードしたアプリ内課金キー(p8ファイル)のパス'

    # 一意なNonceを生成。Nonceの文字列表現には小文字を使用する必要がある
    nonce = SecureRandom.uuid

    app_bundle_id = 'アプリのバンドルID'
    timestamp = (Time.zone.now.to_f * 1000).to_i

    # 不可視の分離文字('\u2063')をパラメータの間にはさみ、以下に示す順でUTF-8文字列に結合します。
    payload = [
      app_bundle_id,
      key_id,
      product_identifier,
      offer_identifier,
      application_username,
      nonce,
      timestamp.to_s,
    ].join("\u2063")

    # アプリ内課金キーを読み込む
    private_key = OpenSSL::PKey::EC.new(File.read(private_key_path))

    digest = OpenSSL::Digest::SHA256.new
    signature = Base64.strict_encode64(private_key.sign(digest, payload))

    # 署名が正しいかチェック(デバッグ用)
    verification_result = private_key.dsa_verify_asn1(digest.digest(payload), Base64.decode64(signature))
    Rails.logger.info("Verification result: #{verification_result}")

    json = { signature:, nonce:, timestamp:, key_id: }
    render json: 
  end
end

署名生成APIの参考

MIXI DEVELOPERS
MIXI DEVELOPERS

Discussion