Skip to content

Instantly share code, notes, and snippets.

@RenGate
Last active May 10, 2023 14:42
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save RenGate/261dd84a02b0a17e18aaa69254923355 to your computer and use it in GitHub Desktop.
Save RenGate/261dd84a02b0a17e18aaa69254923355 to your computer and use it in GitHub Desktop.
Sample implementation of In-App purchase manager class
// MIT License
//
// Copyright (c) 2019 Rostyslav Dovhaliuk
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import RxSwift
import RxCocoa
import StoreKit
import Kvitto
import Crashlytics
enum RestorePurchaseStatus {
case loading
case finished
case failed(error: Error)
}
enum ProductsStatus {
case pending
case loading
case loaded(subscription: SKProduct, proVersion: SKProduct)
case failed(error: Error)
}
enum TransactionStatus {
case pending
case purchasing
case purchased
case failed(error: Error?)
case deferred
}
enum PurchaseStatus {
case noPurchases
case purchasedProVersion
case activeSubscription(expDate: Date)
case expiredSubscription(expDate: Date)
case purchasedProVersionAndSubscription(expDate: Date)
var givesAccessToProTools: Bool {
switch self {
case .purchasedProVersion, .activeSubscription, .purchasedProVersionAndSubscription: return true
default: return false
}
}
var proVersionPurchased: Bool {
switch self {
case .purchasedProVersion, .purchasedProVersionAndSubscription: return true
default: return false
}
}
}
enum PurchaseError: LocalizedError {
case noMatchingProductFound
var errorDescription: String? {
switch self {
case .noMatchingProductFound:
return "Can't find SKProduct with matching identifier"
}
}
}
private enum InAppID: String, CaseIterable {
case subscription = "com.eehelper.pro_subscription"
case proVersion = "com.eehelper.pro_version"
}
private let inAppPurchasesIdentifiers = Set(InAppID.allCases.map { $0.rawValue })
class InAppPurchaseManager: NSObject {
static let shared = InAppPurchaseManager()
let inAppPurchaseStatus: BehaviorRelay<PurchaseStatus> = BehaviorRelay(value: .noPurchases)
let purchaseRestorationEvents: PublishRelay<RestorePurchaseStatus> = PublishRelay()
let productsEvents: BehaviorRelay<ProductsStatus> = BehaviorRelay(value: .pending)
let transactionEvents: BehaviorRelay<TransactionStatus> = BehaviorRelay(value: .pending)
func loadProducts() {
productsEvents.accept(.loading)
let request = SKProductsRequest(productIdentifiers: inAppPurchasesIdentifiers)
request.delegate = self
request.start()
}
func purchase(_ product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
func restorePurchase() {
purchaseRestorationEvents.accept(.loading)
SKPaymentQueue.default().restoreCompletedTransactions()
}
// MARK: - Private
private override init() {
super.init()
inAppPurchaseStatus.accept(readReceipt())
SKPaymentQueue.default().add(self)
}
private func readReceipt() -> PurchaseStatus {
guard let url = Bundle.main.appStoreReceiptURL,
let receipt = Receipt(contentsOfURL: url)
else {
return .noPurchases
}
let proVersionReceipt = receipt.inAppPurchaseReceipts?
.filter({ $0.productIdentifier == InAppID.proVersion.rawValue })
.first
let subscriptionLatestExpDate = receipt.inAppPurchaseReceipts?
.filter({ $0.productIdentifier == InAppID.subscription.rawValue })
.compactMap({ $0.subscriptionExpirationDate })
.sorted(by: { $0 > $1 })
.first
switch (proVersionReceipt, subscriptionLatestExpDate) {
case (nil, nil): return .noPurchases
case (.some, nil): return .purchasedProVersion
case (nil, .some(let date)):
return date > Date() ? .activeSubscription(expDate: date) : .expiredSubscription(expDate: date)
case (.some, .some(let date)):
return date > Date() ? .purchasedProVersionAndSubscription(expDate: date) : .purchasedProVersion
}
}
}
extension InAppPurchaseManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
let products = response.products
guard
let subscription = products.first(where: { $0.productIdentifier == InAppID.subscription.rawValue }),
let nonconsumable = products.first(where: { $0.productIdentifier == InAppID.proVersion.rawValue })
else {
Crashlytics.sharedInstance().recordError(PurchaseError.noMatchingProductFound)
productsEvents.accept(.failed(error: PurchaseError.noMatchingProductFound))
return
}
productsEvents.accept(.loaded(subscription: subscription, proVersion: nonconsumable))
}
func request(_ request: SKRequest, didFailWithError error: Error) {
productsEvents.accept(.failed(error: error))
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions where inAppPurchasesIdentifiers.contains(transaction.payment.productIdentifier) {
switch transaction.transactionState {
case .purchasing:
transactionEvents.accept(.purchasing)
case .purchased, .restored:
transactionEvents.accept(.purchased)
queue.finishTransaction(transaction)
case .failed:
transactionEvents.accept(.failed(error: transaction.error))
queue.finishTransaction(transaction)
case .deferred:
transactionEvents.accept(.deferred)
}
}
inAppPurchaseStatus.accept(readReceipt())
}
}
extension InAppPurchaseManager: SKPaymentTransactionObserver {
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
purchaseRestorationEvents.accept(.finished)
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
purchaseRestorationEvents.accept(.failed(error: error))
}
@available(iOS 14.0, *)
func paymentQueue(_ queue: SKPaymentQueue, didRevokeEntitlementsForProductIdentifiers productIdentifiers: [String]) {
inAppPurchaseStatusRelay.accept(readReceipt())
}
}
@mrugeshtank
Copy link

This is great. I would like to implement in my app. but don't know what is "Receipt".
I know that is out of scope. but if you can provide class or structure of "Receipt" then it would be awesome.

@RenGate
Copy link
Author

RenGate commented Jul 25, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment