LoginSignup
56
61

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-01-26

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に 接続できません」のエラー

56
61
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
61