iOS
課金
Swift
Swift3.0

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

More than 1 year has passed since last update.

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