Skip to content

Commit

Permalink
Adds purchase(_:) and restorePurchases()
Browse files Browse the repository at this point in the history
  • Loading branch information
yusuftor committed Oct 1, 2024
1 parent 4a2a467 commit 8f5e602
Show file tree
Hide file tree
Showing 16 changed files with 333 additions and 83 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 3.9.2
## 3.10.0

### Enhancements

- Adds `purchase(_:)` to initiate a purchase of an `SKProduct` via Superwall regardless of whether you are using paywalls or not.
- Adds `restorePurchases()` to restore purchases via Superwall.

### Fixes

Expand Down
11 changes: 9 additions & 2 deletions Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ final class DependencyContainer {
receiptManager: receiptManager,
sessionEventsManager: sessionEventsManager,
identityManager: identityManager,
storage: storage,
factory: self
)
}
Expand Down Expand Up @@ -468,8 +469,14 @@ extension DependencyContainer: PurchasedTransactionsFactory {
return productPurchaser.coordinator
}

func purchase(product: SKProduct) async -> PurchaseResult {
return await productPurchaser.purchase(product: product)
func purchase(
product: SKProduct,
isExternal: Bool
) async -> PurchaseResult {
return await productPurchaser.purchase(
product: product,
isExternal: isExternal
)
}

func restorePurchases() async -> RestorationResult {
Expand Down
5 changes: 4 additions & 1 deletion Sources/SuperwallKit/Dependencies/FactoryProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ protocol TriggerFactory: AnyObject {

protocol PurchasedTransactionsFactory {
func makePurchasingCoordinator() -> PurchasingCoordinator
func purchase(product: SKProduct) async -> PurchaseResult
func purchase(
product: SKProduct,
isExternal: Bool
) async -> PurchaseResult
func restorePurchases() async -> RestorationResult
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ The ``Superwall`` class is used to access all the features of the SDK. Before us
- ``PaywallSkippedReason``
- ``PaywallSkippedReasonObjc``

### Handling Purchases

- ``purchase(_:)``
- ``purchase(_:completion:)-6oyxm``
- ``purchase(_:completion:)-4rj6r``
- ``restorePurchases()``
- ``restorePurchases(completion:)-4fx45``
- ``restorePurchases(completion:)-4cxt5``

### In-App Previews

- ``handleDeepLink(_:)``
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
3.9.1
3.10.0
"""
8 changes: 8 additions & 0 deletions Sources/SuperwallKit/Storage/Cache/CacheKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,11 @@ enum AdServicesTokenStorage: Storable {
static var directory: SearchPathDirectory = .userSpecificDocuments
typealias Value = String
}

enum SavedTransactions: Storable {
static var key: String {
"store.savedTransactions"
}
static var directory: SearchPathDirectory = .appSpecificDocuments
typealias Value = Set<SavedTransaction>
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ final class AutomaticPurchaseController {
extension AutomaticPurchaseController: PurchaseController {
@MainActor
func purchase(product: SKProduct) async -> PurchaseResult {
return await factory.purchase(product: product)
return await factory.purchase(
product: product,
isExternal: false
)
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import StoreKit
/// When implementing the ``PurchaseController/restorePurchases()`` delegate
/// method, all cases should be considered.
public enum RestorationResult: Sendable, Equatable {
/// The restore was successful – this does not mean the user is subscribed, it just means your restore
/// logic did not fail due to some error. User will see an alert if `Superwall.shared.subscriptionStatus` is
/// not `.active` after returning this value.
/// The restore was successful
///
/// - Warning: This does not mean the user is subscribed, it just means your restore
/// logic did not fail due to some error. User will see an alert if ``Superwall/subscriptionStatus`` is
/// not ``SubscriptionStatus/active`` after returning this value.
case restored

/// The restore failed for some reason (i.e. you were not able to determine if the user has an active subscription.
Expand All @@ -31,6 +33,15 @@ public enum RestorationResult: Sendable, Equatable {
return false
}
}

func toObjc() -> RestorationResultObjc {
switch self {
case .restored:
return .restored
case .failed:
return .failed
}
}
}

/// An enum that defines the possible outcomes of attempting to restore a product.
Expand Down
22 changes: 0 additions & 22 deletions Sources/SuperwallKit/StoreKit/Purchases/ExternalPurchasing.swift

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ final class ProductPurchaserSK1: NSObject {
private let receiptManager: ReceiptManager
private let sessionEventsManager: SessionEventsManager
private let identityManager: IdentityManager
private let storage: Storage
private let factory: HasExternalPurchaseControllerFactory & StoreTransactionFactory

deinit {
Expand All @@ -41,21 +42,26 @@ final class ProductPurchaserSK1: NSObject {
receiptManager: ReceiptManager,
sessionEventsManager: SessionEventsManager,
identityManager: IdentityManager,
storage: Storage,
factory: HasExternalPurchaseControllerFactory & StoreTransactionFactory
) {
self.storeKitManager = storeKitManager
self.receiptManager = receiptManager
self.sessionEventsManager = sessionEventsManager
self.identityManager = identityManager
self.factory = factory
self.storage = storage

super.init()
SKPaymentQueue.default().add(self)
}

/// Purchases a product, waiting for the completion block to be fired and
/// returning a purchase result.
func purchase(product: SKProduct) async -> PurchaseResult {
func purchase(
product: SKProduct,
isExternal: Bool
) async -> PurchaseResult {
let task = Task {
return await withCheckedContinuation { continuation in
Task {
Expand Down Expand Up @@ -124,6 +130,7 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver {
let paywallViewController = Superwall.shared.paywallViewController
let purchaseDate = await coordinator.purchaseDate
for transaction in transactions {
await savePurchasedTransaction(transaction)
await coordinator.storeIfPurchased(transaction)
await checkForTimeout(of: transaction, in: paywallViewController)
await updatePurchaseCompletionBlock(for: transaction, purchaseDate: purchaseDate)
Expand Down Expand Up @@ -166,15 +173,63 @@ extension ProductPurchaserSK1: SKPaymentTransactionObserver {
}
}

private func savePurchasedTransaction(
_ transaction: SKPaymentTransaction
) async {
guard transaction.transactionState == .purchased else {
return
}
guard let id = transaction.transactionIdentifier else {
return
}
var savedTransactions = storage.get(SavedTransactions.self) ?? []
guard savedTransactions.filter({ $0.id == id }).isEmpty else {
return
}
let isExternal = await coordinator.isExternalPurchase
let transaction = SavedTransaction(
id: id,
date: Date(),
hasExternalPurchaseController: factory.makeHasExternalPurchaseController(),
isExternal: isExternal
)
savedTransactions.insert(transaction)
storage.save(savedTransactions, forType: SavedTransactions.self)
}

func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
// Retrieve saved transactions from storage
var savedTransactions = storage.get(SavedTransactions.self) ?? []

// Filter out the transactions that have been removed
let removedTransactionIDs = Set(transactions.compactMap { $0.transactionIdentifier })

savedTransactions = savedTransactions.filter { savedTransaction in
// Keep only transactions that are not in the removedTransactionIDs
!removedTransactionIDs.contains(savedTransaction.id)
}

// Save the updated savedTransactions back to storage
storage.save(savedTransactions, forType: SavedTransactions.self)
}

/// Sends a `PurchaseResult` to the completion block and stores the latest purchased transaction.
private func updatePurchaseCompletionBlock(
for skTransaction: SKPaymentTransaction,
purchaseDate: Date?
) async {
// Only continue if using internal purchase controller. The transaction may be
// Doesn't continue if the purchase was initiated internally and an
// external purchase controller is being used. The transaction may be
// readded to the queue if finishing fails so we need to make sure
// we can re-finish the transaction.
if factory.makeHasExternalPurchaseController() {
let savedTransactions = storage.get(SavedTransactions.self) ?? []
guard let savedTransaction = savedTransactions.first(
where: { $0.id == skTransaction.transactionIdentifier }
) else {
return
}
if !savedTransaction.isExternal,
savedTransaction.hasExternalPurchaseController {
return
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// PurchaseSource.swift
// SuperwallKit
//
// Created by Yusuf Tör on 01/10/2024.
//

/// The source of the purchase initiation.
enum PurchaseSource {
/// The purchase was initiated internally by the SDK.
case `internal`(String, PaywallViewController)

/// The purchae was initiated externally by the user calling ``Superwall/purchase(_:)-7gwwe``.
case external(StoreProduct)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import StoreKit
actor PurchasingCoordinator {
private var completion: ((PurchaseResult) -> Void)?
var productId: String?
var isExternalPurchase = false
var lastInternalTransaction: SKPaymentTransaction?
var purchaseDate: Date?
var transactions: [String: SKPaymentTransaction] = [:]
Expand All @@ -31,9 +32,13 @@ actor PurchasingCoordinator {
self.completion = completion
}

func beginPurchase(of productId: String) {
func beginPurchase(
of productId: String,
isExternal: Bool
) {
self.purchaseDate = Date()
self.productId = productId
self.isExternalPurchase = isExternal
}

/// Gets the latest transaction of a specified product ID. Used with purchases, including when a purchase has
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// SavedTransaction.swift
// SuperwallKit
//
// Created by Yusuf Tör on 01/10/2024.
//

import Foundation

/// The transaction to save to storage.
struct SavedTransaction: Codable, Hashable {
/// The transaction id.
let id: String

/// The date the transaction was created.
let date: Date

/// Whether or not the developer is using a purchase controller.
let hasExternalPurchaseController: Bool

/// Indicates whether the purchase was initiated externally via the
/// developer rather than internally via the SDK.
let isExternal: Bool
}
Loading

0 comments on commit 8f5e602

Please sign in to comment.