Skip to content

Instantly share code, notes, and snippets.

@jordibruin
Last active November 29, 2022 10:07
Show Gist options
  • Save jordibruin/5b5def78679eefae7379af6f01c67abb to your computer and use it in GitHub Desktop.
Save jordibruin/5b5def78679eefae7379af6f01c67abb to your computer and use it in GitHub Desktop.
//
// Store.swift
// Posture Pal
//
// Created by Jordi Bruin on 28/02/2022.
//
import Foundation
import Foundation
import StoreKit
import Combine
import SwiftUI
typealias Transaction = StoreKit.Transaction
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
public enum StoreError: Error {
case failedVerification
}
class Store: NSObject, ObservableObject, SKPaymentTransactionObserver {
@Published var transactionState: SKPaymentTransactionState?
@Published private(set) var freeTrialProduct: Product?
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
print("updating state")
print(transaction.transactionState)
switch transaction.transactionState {
case .purchasing:
transactionState = .purchasing
case .purchased:
transactionState = .purchased
case .restored:
transactionState = .restored
case .failed, .deferred:
transactionState = .failed
default:
queue.finishTransaction(transaction)
}
}
}
@Published private(set) var nonConsumables: [Product]
@Published private(set) var subscriptions: [Product]
@Published private(set) var purchasedIdentifiers = Set<String>()
@AppStorage("hasFullAccess") var hasFullAccess : Bool = false {
willSet { objectWillChange.send() }
}
@Published var activeProduct : Product?
@Published private(set) var transactions : [Transaction]
var taskHandle: Task<Void, Error>? = nil
private static let subscriptionTier: [String: SubscriptionTier] = [
Constants.Products.annual: .yearly,
Constants.Products.monthly: .monthly
]
private let products: [String:String] = [
Constants.Products.lifetime: "lifetime",
Constants.Products.annual: "Annual",
Constants.Products.monthly: "Monthly",
]
/// The ids for products which unlock all functionality
private let fullAccessIDs = [
Constants.Products.lifetime,
Constants.Products.annual,
Constants.Products.monthly
]
override init() {
//Initialize empty products then do a product request asynchronously to fill them in.
activeProduct = nil
nonConsumables = []
subscriptions = []
transactions = []
super.init()
//Start a transaction listener as close to app launch as possible so you don't miss any transactions.
taskHandle = listenForTransactions()
Task {
//Initialize the store by starting a product request.
await requestProducts()
// Checks if any Full access products has been purchased
await checkForAccess()
// print("check for access")
await getPurchasedProducts()
}
}
deinit {
print("deinit")
taskHandle?.cancel()
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached(priority: .medium, operation: {
//Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
print(result)
do {
let transaction = try self.checkVerified(result)
//Deliver content to the user.
await self.updatePurchasedIdentifiers(transaction)
await self.updateTransactions(transaction)
//Always finish a transaction.
await transaction.finish()
} catch {
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
// await self.declineAccess()
print("Transaction failed verification")
}
}
})
}
/// Retrieve the products from App Store Connect
@MainActor
func requestProducts() async {
do {
let storeProducts = try await Product.products(for: products.keys)
var newSubscriptions: [Product] = []
//Filter the products into different categories based on their type.
for product in storeProducts {
switch product.type {
case .autoRenewable:
// print("found subscription")
newSubscriptions.append(product)
// print(product.id)
// print(tier(for: product.id))
if tier(for: product.id) == .yearly {
freeTrialProduct = product
}
continue
case .nonConsumable:
nonConsumables.append(product)
continue
case .consumable:
print("found Consumable")
nonConsumables.append(product)
continue
default:
print("Unknown product")
}
}
//Sort each product category by price, lowest to highest, to update the store.
subscriptions = sortByPrice(newSubscriptions)
} catch {
print("Failed product request: \(error)")
}
}
@MainActor
func declineAccess() {
self.hasFullAccess = false
}
@MainActor
func checkForAccess() async {
// #warning("🛑 remove")
declineAccess()
for await result in Transaction.currentEntitlements {
if case .verified(let resultTransaction) = result {
switch resultTransaction.productType {
case .nonConsumable:
self.hasFullAccess = true
case .autoRenewable:
self.hasFullAccess = true
default:
//This type of product isn't displayed in this view.
declineAccess()
}
} else {
declineAccess()
}
}
//#warning("🛑 ReMOVE TO MAKE MONEY")
// self.hasFullAccess = true
}
@MainActor
func purchase(_ product: Product) async throws -> Transaction? {
//Begin a purchase.
let result = try await product.purchase()
switch result {
case .success(let verification):
self.hasFullAccess = true
let transaction = try checkVerified(verification)
//Deliver content to the user.
await updatePurchasedIdentifiers(transaction)
await updateTransactions(transaction)
//Always finish a transaction.
await transaction.finish()
return transaction
case .userCancelled, .pending:
return nil
default:
return nil
}
}
func productFromID(_ id: String) -> Product? {
let allProducts = subscriptions
let filteredProducts = allProducts.filter { $0.id == id }
return filteredProducts.first ?? nil
}
func isPurchased(_ productIdentifier: String) async throws -> Bool {
//Get the most recent transaction receipt for this `productIdentifier`.
guard let result = await Transaction.latest(for: productIdentifier) else {
//If there is no latest transaction, the product has not been purchased.
return false
}
let transaction = try checkVerified(result)
//Ignore revoked transactions, they're no longer purchased.
//tier will then have the `isUpgraded` flag set and there will be a new transaction for the higher service
//tier. Ignore the lower service tier transactions which have been upgraded.
return transaction.revocationDate == nil && !transaction.isUpgraded
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
//Check if the transaction passes StoreKit verification.
switch result {
case .unverified:
//StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
throw StoreError.failedVerification
case .verified(let safe):
//If the transaction is verified, unwrap and return it.
return safe
}
}
@MainActor
func updatePurchasedIdentifiers(_ transaction: Transaction) async {
if transaction.revocationDate == nil {
//If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`.
purchasedIdentifiers.insert(transaction.productID)
} else {
//If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`.
purchasedIdentifiers.remove(transaction.productID)
}
}
@MainActor
func updateTransactions(_ transaction: Transaction) async {
self.transactions.append(transaction)
self.transactions = sortByDate(self.transactions)
if fullAccessIDs.contains(transaction.productID) {
self.activeProduct = productFromID(transaction.productID)
}
}
@MainActor
func getPurchasedProducts() async {
//Iterate through all of the user's purchased products.
for product in fullAccessIDs {
if case .verified(let transaction) = await Transaction.latest(for: product) {
if !self.transactions.contains(transaction) {
self.transactions.append(transaction)
}
}
}
let transactions = Transaction.all
_ = await transactions.contains { result in
self.hasFullAccess = true
return true
}
}
func sortByPrice(_ products: [Product]) -> [Product] {
return products.sorted(by: { return $0.price < $1.price })
}
func sortByDate(_ transaction: [Transaction]) -> [Transaction] {
transaction.sorted(by: { return $0.purchaseDate > $1.purchaseDate })
}
func tier(for productId: String) -> SubscriptionTier {
switch productId {
case Constants.Products.lifetime:
return .lifetime
case Constants.Products.annual:
return .yearly
case Constants.Products.monthly:
return .monthly
default:
return .none
}
}
}
struct Constants {
struct Products {
static let lifetime: String = "super.sticky.timers"
static let annual: String = "supersticky.annual"
static let monthly: String = "supersticky.monthly"
static let monthlyNoTrial: String = "supersticky.monthly.notrial"
}
// struct Analytics {
// static let identifier: String = "goodsnooze.stickytimers"
// }
}
public enum SubscriptionTier: Int, Comparable {
case none = 0
case monthly = 1
case yearly = 2
case old = 3
case lifetime = 4
public static func < (lhs: Self, rhs: Self) -> Bool {
return lhs.rawValue < rhs.rawValue
}
}
import Foundation
class PeriodFormatter {
static var componentFormatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.maximumUnitCount = 1
formatter.unitsStyle = .full
formatter.zeroFormattingBehavior = .dropAll
return formatter
}
static func format(unit: NSCalendar.Unit, numberOfUnits: Int) -> String? {
var dateComponents = DateComponents()
dateComponents.calendar = Calendar.current
componentFormatter.allowedUnits = [unit]
switch unit {
case .day:
dateComponents.setValue(numberOfUnits, for: .day)
case .weekOfMonth:
dateComponents.setValue(numberOfUnits, for: .weekOfMonth)
case .month:
dateComponents.setValue(numberOfUnits, for: .month)
case .year:
dateComponents.setValue(numberOfUnits, for: .year)
default:
return nil
}
return componentFormatter.string(from: dateComponents)
}
}
import StoreKit
@available(iOS 11.2, *)
extension SKProduct.PeriodUnit {
func toCalendarUnit() -> NSCalendar.Unit {
switch self {
case .day:
return .day
case .month:
return .month
case .week:
return .weekOfMonth
case .year:
return .year
@unknown default:
debugPrint("Unknown period unit")
}
return .day
}
}
import StoreKit
@available(iOS 11.2, *)
extension SKProductSubscriptionPeriod {
func localizedPeriod() -> String? {
return PeriodFormatter.format(unit: unit.toCalendarUnit(), numberOfUnits: numberOfUnits)
}
}
import StoreKit
@available(iOS 11.2, *)
extension SKProductDiscount {
func localizedDiscount() -> String? {
switch paymentMode {
case PaymentMode.freeTrial:
return "Free trial for \(subscriptionPeriod.localizedPeriod() ?? "a period")"
default:
return nil
}
}
}
extension Product {
var trialAmount: Int? {
return self.subscription?.introductoryOffer?.period.value
}
var trialUnit: String? {
return self.subscription?.introductoryOffer?.period.unit.localizedDescription.lowercased()
}
var subUnit: String? {
return self.subscription?.subscriptionPeriod.unit.localizedDescription ?? ""
}
var hasFreeTrial: Bool {
return self.subscription?.introductoryOffer != nil
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment