Last active
November 29, 2022 10:07
-
-
Save jordibruin/5b5def78679eefae7379af6f01c67abb to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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