post Image
Swift3による自動更新購読のアプリ内課金(In-App Purchase)の実装 for 月額課金

iOS開発におけるアプリ内課金には以下の4種類があります。

  • 消耗型
  • 非消耗型
  • 自動更新登録
  • 非更新登録

ここでは、Swift3による月額課金の自動更新処理を行います。

iTunes Connectでプロダクトの登録

iTunes Connect(https://itunesconnect.apple.com ) へアクセスし、[マイApp]から[新規App]を作成し、[自動更新登録]のApp内課金を追加しておく。

参照名: <iTunes Connect上で表示されるプロダクト名>
製品ID: xxx.xxx.xxx.xxx(以下、製造IDはこの表記を使用する)

この時、登録期間として以下の6種類が選択できるので1か月を選択しておく。

  • 1週間
  • 1か月
  • 2か月
  • 3か月
  • 6か月
  • 1年

無料トライアルとして、以下の3種類が選択できる。

  • なし
  • 1週間
  • 1か月

なお、App内課金のページにおける[共有シークレットを表示]で表示される共有シークレットは後のSKPurchaseManagerのpasswordに使用するのでメモしておく。

また、[ユーザと役割]から画面上部の[Sandboxテスター]を選択し、テストユーザーを追加しておく。

課金処理

処理の流れ

以下のように大きく3つの流れとなる。

  1. 登録されているプロダクトの取得
  2. プロダクトの購読処理
  3. 購読の有効性(レシート)の確認

関連知識

レシート検証先URL

レシートをApp Storeに送るが、送り先のURLは以下のようになる。

レシート検証後のレスポンス

構成要素

  • status : statusが0の場合は正常。それ以外はエラー。
  • receipt : 送信したレシートのjson
  • latest_receipt : 自動購読処理の情報をbase64エンコードされた文字列
  • latest_receipt_info : 自動購読処理の情報のjson

ステータスコード

  • 21000 : JSONが無効
  • 21002 : レシートデータの要素が無効
  • 21003 : 認証エラー
  • 21004 : 共通シークレットの不一致
  • 21005 : レシートサーバのエラー
  • 21007 : プロダクション用のレシートが無効
  • 21008 : サンドボックス用のレシートが無効

実装

実装ファイルは以下の4つとなる。

  • AppDelegate.swift
  • SampleViewController.swift
  • SKProductManager.swift
  • SKPurchaseManager.swift

SKProductManager.swiftとSKPurchaseManager.swiftはそのまま設置する形で、AppDelegate.swiftとSampleViewController.swiftは必要なコードを追記する形となる。

  • AppDelegate.swift
import UIKit
import StoreKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, SKPaymentManagerDelegate {

    var window: UIWindow?


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // 定期購読処理
        SKPaymentManager.shared().delegate = self
        SKPaymentQueue.default().add(SKPaymentManager.shared())

        // 定期購読確認
        SKPaymentManager.checkReceipt()

        return true
    }
}
  • SampleViewController.swift
import UIKit
import StoreKit

class SampleViewController: UIViewController, SKPaymentManagerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        SKProductManager.getSubscriptionProduct()
    }

    @IBAction func purchaseButtonPushed(_ sender: Any) {
        startPurchase()
    }

    fileprivate func startPurchase() {
        if let product = SKProductManager.subscriptionProduct {
            SKPaymentManager.shared().delegate = self
            SKPaymentManager.shared().startWithProduct(product: product)
            return
        }
    }

    func purchaseManager(purchaseManager: SKPaymentManager, didFinishPurchaseWithTransaction transaction: SKPaymentTransaction!, decisionHandler: ((_ complete: Bool) -> Void)!) {
        decisionHandler(true)
        popAfterPurchase()
    }

    func purchaseManager(purchaseManager: SKPaymentManager, didFinishUntreatedPurchaseWithTransaction transaction: SKPaymentTransaction!, decisionHandler: ((_ complete: Bool) -> Void)!) {
        decisionHandler(true)
        popAfterPurchase()
    }

    func purchaseManager(purchaseManager: SKPaymentManager, didFailWithError error: NSError!) {
        print("Error")
    }

    func purchaseManagerDidFinishRestore(purchaseManager: SKPaymentManager) {
        popAfterPurchase()
    }

    func purchaseManagerDidDeferred(purchaseManager: SKPaymentManager) {
        print("Deferred")
    }

    func popAfterPurchase() {
        // Move to another display
    }
}
  • SKProductManager.swift
import Foundation
import StoreKit

fileprivate var productManagers : Set<SKProductManager> = Set()

class SKProductManager: NSObject, SKProductsRequestDelegate {
    static var subscriptionProduct : SKProduct? = nil

    fileprivate var completion : (([SKProduct]?,NSError?) -> Void)?

    static func getProducts(withProductIdentifiers productIdentifiers : [String],completion:(([SKProduct]?,NSError?) -> Void)?){
        let productManager = SKProductManager()
        productManager.completion = completion
        let request = SKProductsRequest(productIdentifiers: Set(productIdentifiers))
        request.delegate = productManager
        request.start()

        productManagers.insert(productManager)
    }

    static func getSubscriptionProduct(completion:(() -> Void)? = nil) {
        guard SKProductManager.subscriptionProduct == nil else {
            if let completion = completion {
                completion()
            }
            return
        }

        let productIdentifier = "xxx.xxx.xxx.xxx"

        SKProductManager.getProducts(withProductIdentifiers: [productIdentifier], completion: { (_products, error) -> Void in
            if let product = _products?.first {
                SKProductManager.subscriptionProduct = product
            }
            if let completion = completion {
                completion()
            }
        })

    }

    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        var error : NSError? = nil
        if response.products.count == 0 {
            error = NSError(domain: "ProductsRequestErrorDomain", code: 0, userInfo: [NSLocalizedDescriptionKey:"プロダクトを取得できませんでした。"])
        }
        completion?(response.products, error)
    }

    func request(_ request: SKRequest, didFailWithError error: Error) {
        let error = NSError(domain: "ProductsRequestErrorDomain", code: 0, userInfo: [NSLocalizedDescriptionKey:"プロダクトを取得できませんでした。"])
        completion?(nil,error)
        productManagers.remove(self)
    }

    func requestDidFinish(_ request: SKRequest) {
        productManagers.remove(self)
    }
}
  • SKPaymentManager.swift
import Foundation
import StoreKit

@objc protocol SKPaymentManagerDelegate {
    @objc optional func purchaseManager(purchaseManager: SKPaymentManager, didFinishPurchaseWithTransaction transaction: SKPaymentTransaction!, decisionHandler: ((_ complete : Bool) -> Void)!)
    @objc optional func purchaseManager(purchaseManager: SKPaymentManager, didFinishUntreatedPurchaseWithTransaction transaction: SKPaymentTransaction!, decisionHandler: ((_ complete : Bool) -> Void)!)
    @objc optional func purchaseManagerDidFinishRestore(purchaseManager: SKPaymentManager)
    @objc optional func purchaseManager(purchaseManager: SKPaymentManager, didFailWithError error: NSError!)
    @objc optional func purchaseManagerDidDeferred(purchaseManager: SKPaymentManager)
}

enum SKError : Error {
    case invalidAppStoreReceiptURL
    case invalidURL(url:String)
}

enum ReceiptStatusError : Error {
    case invalidJson
    case invalidReceiptDataProperty
    case authenticationError
    case commonSecretKeyMisMatch
    case receiptServerError
    case invalidReceiptForProduction
    case invalidReceiptForSandbox
    case unknownError
    static func statusForErrorCode(_ _code:Any?) -> ReceiptStatusError? {
        guard let code = _code as? Int else {
            return .unknownError
        }
        switch code {
        case 0:
            return nil
        case 21000:
            return .invalidJson
        case 21002:
            return .invalidReceiptDataProperty
        case 21003:
            return .authenticationError
        case 21004:
            return .commonSecretKeyMisMatch
        case 21005:
            return .receiptServerError
        case 21007:
            return .invalidReceiptForProduction
        case 21008:
            return .invalidReceiptForSandbox
        default:
            return .unknownError
        }
    }
}


fileprivate let singleton = SKPaymentManager()

class SKPaymentManager : NSObject,SKPaymentTransactionObserver {
    var delegate : SKPaymentManagerDelegate?

    fileprivate var productIdentifier : String?
    fileprivate var isRestore : Bool = false
    fileprivate static var receiptStatus: ReceiptStatusError? = nil

    fileprivate static var verifyReceiptUrlString: String {
       //#if DEBUG
       //    return "https://sandbox.itunes.apple.com/verifyReceipt"
       //#else
       //    return "https://buy.itunes.apple.com/verifyReceipt"
       //#endif
       return "https://sandbox.itunes.apple.com/verifyReceipt"
    }

    fileprivate static var verifyReceiptUrl : URL? {
        return URL(string: verifyReceiptUrlString)
    }

    fileprivate static var password : String {
        return "xxxxxxxxxxxxxxxxxxxxxxxxxx" //iTunes ConnectにおけるApp内課金で発行される共有シークレット
    }

    class func shared() -> SKPaymentManager {
        return singleton;
    }

    func startWithProduct(product : SKProduct){
        var errorCount = 0
        var errorMessage = ""

        if SKPaymentQueue.canMakePayments() == false {
            errorCount += 1
            errorMessage = "設定で購入が無効になっています。"
        }

        if productIdentifier != nil {
            errorCount += 10
            errorMessage = "課金処理中です。"
        }

        if isRestore == true {
            errorCount += 100
            errorMessage = "リストア中です。"
        }

        if errorCount > 0 {
            let error = NSError(domain: "PurchaseErrorDomain", code: errorCount, userInfo: [NSLocalizedDescriptionKey:errorMessage + "(\(errorCount))"])
            delegate?.purchaseManager!(purchaseManager: self, didFailWithError: error)
            return
        }

        let payment = SKMutablePayment(product: product)
        SKPaymentQueue.default().add(payment)
        productIdentifier = product.productIdentifier
    }

    func startRestore(){
        if isRestore == false {
            isRestore = true
            SKPaymentQueue.default().restoreCompletedTransactions()
        }else{
            let error = NSError(domain: "PurchaseErrorDomain", code: 0, userInfo: [NSLocalizedDescriptionKey:"リストア処理中です。"])
            delegate?.purchaseManager?(purchaseManager: self, didFailWithError: error)
        }
    }

    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchasing :
                break
            case .purchased :
                completeTransaction(transaction: transaction)
            case .failed :
                failedTransaction(transaction: transaction)
            case .restored :
                restoreTransaction(transaction: transaction)
            case .deferred :
                deferredTransaction(transaction: transaction)
            }
        }
    }

    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
        delegate?.purchaseManager?(purchaseManager: self, didFailWithError: error as NSError!)
        isRestore = false
    }

    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        delegate?.purchaseManagerDidFinishRestore?(purchaseManager: self)
        isRestore = false
    }

    private func completeTransaction(transaction : SKPaymentTransaction) {
        if transaction.payment.productIdentifier == productIdentifier {
            delegate?.purchaseManager?(purchaseManager: self, didFinishPurchaseWithTransaction: transaction, decisionHandler: { (complete) -> Void in
                if complete == true {
                    SKPaymentQueue.default().finishTransaction(transaction)
                    SKPaymentManager.checkReceipt()
                }
            })
            productIdentifier = nil
        } else {
            delegate?.purchaseManager?(purchaseManager: self, didFinishUntreatedPurchaseWithTransaction: transaction, decisionHandler: { (complete) -> Void in
                if complete == true {
                    SKPaymentQueue.default().finishTransaction(transaction)
                    SKPaymentManager.checkReceipt()
                }
            })
        }
    }

    fileprivate func failedTransaction(transaction : SKPaymentTransaction) {
        delegate?.purchaseManager?(purchaseManager: self, didFailWithError: transaction.error as NSError!)
        productIdentifier = nil
        SKPaymentQueue.default().finishTransaction(transaction)
    }

    fileprivate func restoreTransaction(transaction : SKPaymentTransaction) {
        switch transaction.transactionState {
        case .purchasing :
            break
        case .purchased :
            break
        case .failed :
            break
        case .restored :
            break
        case .deferred :
            break
        }

        delegate?.purchaseManager?(purchaseManager: self, didFinishPurchaseWithTransaction: transaction.original, decisionHandler: { (complete) -> Void in
            if complete == true {
                SKPaymentQueue.default().finishTransaction(transaction)
            }
        })
    }

    fileprivate func deferredTransaction(transaction : SKPaymentTransaction) {
        delegate?.purchaseManagerDidDeferred?(purchaseManager: self)
        productIdentifier = nil
    }

    public static func checkReceipt() {
        do {
            let reqeust = try getReceiptRequest()
            let session = URLSession.shared
            let task = session.dataTask(with: reqeust, completionHandler: {(data, response, error) -> Void in
                guard let jsonData = data else { return }

                do {
                    let json = try JSONSerialization.jsonObject(with: jsonData, options: .init(rawValue: 0)) as AnyObject
                    receiptStatus = ReceiptStatusError.statusForErrorCode(json.object(forKey: "status"))
                    guard let latest_receipt_info = (json as AnyObject).object(forKey: "latest_receipt_info") else { return }
                    guard let receipts = latest_receipt_info as? [[String: AnyObject]] else { return }
                    updateStatus(receipts: receipts)
                } catch let error {
                    print("SKPaymentManager : Failure to validate receipt: \(error)")
                }
            })
            task.resume()
        } catch let error {
            print("SKPaymentManager : Failure to process payment from Apple store: \(error)")
            checkReceiptInLocal()
        }
    }

    fileprivate static func checkReceiptInLocal() {
        let expiresDateMs : UInt64 = 0 //ローカルから取り出してくる
        let nowDateMs: UInt64 = getNowDateMs()

        if nowDateMs <= expiresDateMs {
            print("OK")
        }
    }

    fileprivate static func getReceiptRequest() throws -> URLRequest {
        guard let receiptUrl = Bundle.main.appStoreReceiptURL else {
            throw SKError.invalidAppStoreReceiptURL
        }

        let receiptData = try Data(contentsOf: receiptUrl)
        let receiptBase64Str = receiptData.base64EncodedString(options: .endLineWithCarriageReturn)
        let requestContents = ["receipt-data": receiptBase64Str, "password": password]
        let requestData = try JSONSerialization.data(withJSONObject: requestContents, options: .init(rawValue: 0))


        guard let verifyUrl = verifyReceiptUrl else {
            throw SKError.invalidURL(url: verifyReceiptUrlString)
        }
        var request = URLRequest(url: verifyUrl)

        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField:"content-type")
        request.timeoutInterval = 5.0
        request.httpMethod = "POST"
        request.httpBody = requestData

        return request
    }

    fileprivate static func updateStatus(receipts: [[String: AnyObject]]) {
        var productId: String = ""
        var expiresDateMs: UInt64 = 0
        for receipt in receipts {
            productId = receipt["product_id"] as? String ?? ""
            expiresDateMs = UInt64(receipt["expires_date_ms"] as? String ?? "0") ?? 0
            let nowDateMs: UInt64 = getNowDateMs()
            if nowDateMs <= expiresDateMs
                && receiptStatus == nil
                && productId == "xxx.xxx.xxx.xxx" {
                print("OK")
            }
        }
        //ローカルのデータを更新
        //productID, purchaseDateMs, expiresDateMs, isTrialPeriod など
    }

    fileprivate static func getNowDateMs() -> UInt64 {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        let dateNowStr: String = formatter.string(from: Date())
        guard let now: Date = formatter.date(from: dateNowStr) else { return 0 }
        let dateNowUnix: TimeInterval = (now.timeIntervalSince1970)

        return UInt64(dateNowUnix) * 1000
    }    
}

ここではクライアント側でのレシートの検証を行っていますが、JailBreak等により改ざんして使用される可能性があるのでSKPaymentManager.checkReceipt()に当たる処理はサーバサイド側で行うことが推奨されています。

実機テスト

課金処理時にサインインが求められるが、先ほど作成したテストユーザーの情報を入力する。

実機テストした時に「error 0 iTunes Storeに 接続できません」のエラーが出て時の対処法について、一度端末上のApple Storeで[おすすめ]タブの最下部からApple IDを選択し、サインアウトする。再び課金処理の際にログインが求められるので、作成しておいたテストユーザーでログインする。

参考

公式

https://developer.apple.com/in-app-purchase/
https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW3

その他

http://qiita.com/yuu_ta/items/4bfc0d43aa5523057872
http://qiita.com/sora/items/9116a12dcaed27ba27e1
http://qiita.com/sora/items/e65ed31493a2d9b8f1dd
http://qiita.com/monoqlo/items/24d36e3a95bc813a7276
http://stefansdevplayground.blogspot.jp/2015/04/how-to-implement-in-app-purchase-for.html
http://ameblo.jp/principia-ca/entry-12071724382.html
http://amarron.hatenablog.com/entry/2014/05/24/093913
http://qiita.com/econa77/items/1f653ff6e0ab151a6eae

error 0 iTunes Storeに 接続できません」のエラー

http://u2k772.blog95.fc2.com/blog-entry-297.html


『 Swift 』Article List