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つの流れとなる。
- 登録されているプロダクトの取得
- プロダクトの購読処理
- 購読の有効性(レシート)の確認
##関連知識
###レシート検証先URL
レシートをApp Storeに送るが、送り先のURLは以下のようになる。
- Sandbox
https://sandbox.itunes.apple.com/verifyReceipt - Production
https://buy.itunes.apple.com/verifyReceipt
###レシート検証後のレスポンス
####構成要素
- 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