Skip to content

Commit

Permalink
Create a StoreTransaction object
Browse files Browse the repository at this point in the history
The `StoreTransaction` object serves as a wrapper for both `SKTransaction` and `StoreKit.Transaction`.
  • Loading branch information
ns-vasilev committed Dec 27, 2023
1 parent efa0928 commit bd616d9
Show file tree
Hide file tree
Showing 26 changed files with 690 additions and 127 deletions.
14 changes: 14 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Flare_FlareTests"
BuildableName = "Flare_FlareTests"
BlueprintName = "Flare_FlareTests"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand Down
6 changes: 3 additions & 3 deletions [email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ let package = Package(
"Flare",
.product(name: "ObjectsFactory", package: "objects-factory"),
.product(name: "TestConcurrency", package: "concurrency"),
],
resources: [
.process("Flare.storekit"),
]
// resources: [
// .process("Flare.storekit"),
// ]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Flare
// Copyright © 2023 Space Code. All rights reserved.
//

import Foundation

extension AsyncSequence {
func toAsyncStream() -> AsyncStream<Element> {
var asyncIterator = makeAsyncIterator()
return AsyncStream<Element> {
try? await asyncIterator.next()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Flare
// Copyright © 2023 Space Code. All rights reserved.
//

import Foundation
import StoreKit

protocol ITransactionListener: Sendable {
func listenForTransaction() async

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func handle(purchaseResult: StoreKit.Product.PurchaseResult) async throws -> StoreTransaction?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// Flare
// Copyright © 2023 Space Code. All rights reserved.
//

import Foundation
import StoreKit

// MARK: - TransactionListener

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
actor TransactionListener {
// MARK: Types

typealias TransactionResult = StoreKit.VerificationResult<StoreKit.Transaction>

// MARK: Private

private let updates: AsyncStream<TransactionResult>
private var task: Task<Void, Never>?

// MARK: Initialization

init<S: AsyncSequence>(updates: S) where S.Element == TransactionResult {
self.updates = updates.toAsyncStream()
}

// MARK: Private

private func handle(
transactionResult: TransactionResult,
fromTransactionUpdate _: Bool
) async throws -> StoreTransaction {
switch transactionResult {
case let .verified(transaction):
return StoreTransaction(
transaction: transaction,
jwtRepresentation: transactionResult.jwsRepresentation
)
case let .unverified(transaction, verificationError):
throw IAPError.verification(
error: .unverified(productID: transaction.productID, error: verificationError)
)
}
}
}

// MARK: ITransactionListener

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
extension TransactionListener: ITransactionListener {
func listenForTransaction() async {
task?.cancel()
task = Task(priority: .utility) { [weak self] in
guard let self = self else { return }

for await update in self.updates {
Task.detached {
do {
_ = try await self.handle(transactionResult: update, fromTransactionUpdate: true)
} catch {
debugPrint("[TransactionListener] Error occurred: \(error.localizedDescription)")
}
}
}
}
}

func handle(purchaseResult: Product.PurchaseResult) async throws -> StoreTransaction? {
switch purchaseResult {
case let .success(verificationResult):
return try await handle(transactionResult: verificationResult, fromTransactionUpdate: false)
case .userCancelled:
throw IAPError.paymentCancelled
case .pending:
throw IAPError.paymentDefferred
@unknown default:
throw IAPError.unknown
}
}
}
8 changes: 8 additions & 0 deletions Sources/Flare/Classes/Models/IAPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public enum IAPError: Swift.Error {
case transactionNotFound(productID: String)
/// The refund error.
case refund(error: RefundError)
/// The verification error.
///
/// - Note: This is only available for StoreKit 2 transactions.
case verification(error: VerificationError)
///
///
/// - Note: This is only available for StoreKit 2 transactions.
case paymentDefferred
/// The unknown error occurred.
case unknown
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ protocol IStoreTransaction {

/// The raw JWS repesentation of the transaction.
///
/// - Note: this is only available for StoreKit 2 transactions.
/// - Note: This is only available for StoreKit 2 transactions.
var jwsRepresentation: String? { get }

/// The server environment where the receipt was generated.
///
/// - Note: this is only available for StoreKit 2 transactions.
/// - Note: This is only available for StoreKit 2 transactions.
var environment: StoreEnvironment? { get }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class SK2StoreProduct {
// MARK: Properties

/// The store kit product.
private let product: StoreKit.Product
let product: StoreKit.Product
/// The currency format.
private var currencyFormat: Decimal.FormatStyle.Currency {
product.priceFormatStyle
Expand Down
3 changes: 3 additions & 0 deletions Sources/Flare/Classes/Models/StoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public final class StoreProduct: NSObject {
/// Protocol representing a Store Kit product.
private let product: ISKProduct

/// <#Description#>
var underlyingProduct: ISKProduct { product }

// MARK: Initialization

/// Creates a new `StoreProduct` instance.
Expand Down
2 changes: 1 addition & 1 deletion Sources/Flare/Classes/Models/StoreTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public final class StoreTransaction {
// MARK: Properties

/// The StoreKit transaction.
private let storeTransaction: IStoreTransaction
let storeTransaction: IStoreTransaction

// MARK: Initialization

Expand Down
10 changes: 10 additions & 0 deletions Sources/Flare/Classes/Models/VerificationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// Flare
// Copyright © 2023 Space Code. All rights reserved.
//

import Foundation

public enum VerificationError: Swift.Error {
case unverified(productID: String, error: Error)
}
34 changes: 12 additions & 22 deletions Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ final class IAPProvider: IIAPProvider {
private let paymentQueue: PaymentQueue
/// The provider is responsible for fetching StoreKit products.
private let productProvider: IProductProvider
/// The provider is responsible for making in-app payments.
private let paymentProvider: IPaymentProvider
/// The provider is responsible for purchasing products.
private let purchaseProvider: IPurchaseProvider
/// The provider is responsible for refreshing receipts.
private let receiptRefreshProvider: IReceiptRefreshProvider
/// The provider is responsible for refunding purchases
Expand All @@ -27,21 +27,21 @@ final class IAPProvider: IIAPProvider {
/// - Parameters:
/// - paymentQueue: The queue of payment transactions to be processed by the App Store.
/// - productProvider: The provider is responsible for fetching StoreKit products.
/// - paymentProvider: The provider is responsible for making in-app payments.
/// - purchaseProvider:
/// - receiptRefreshProvider: The provider is responsible for refreshing receipts.
/// - refundProvider: The provider is responsible for refunding purchases.
init(
paymentQueue: PaymentQueue = SKPaymentQueue.default(),
productProvider: IProductProvider = ProductProvider(),
paymentProvider: IPaymentProvider = PaymentProvider(),
purchaseProvider: IPurchaseProvider = PurchaseProvider(),
receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(),
refundProvider: IRefundProvider = RefundProvider(
systemInfoProvider: SystemInfoProvider()
)
) {
self.paymentQueue = paymentQueue
self.productProvider = productProvider
self.paymentProvider = paymentProvider
self.purchaseProvider = purchaseProvider
self.receiptRefreshProvider = receiptRefreshProvider
self.refundProvider = refundProvider
}
Expand Down Expand Up @@ -80,7 +80,7 @@ final class IAPProvider: IIAPProvider {
}
}

func purchase(productID: String, completion: @escaping Closure<Result<PaymentTransaction, IAPError>>) {
func purchase(productID: String, completion: @escaping Closure<Result<StoreTransaction, IAPError>>) {
productProvider.fetch(productIDs: [productID], requestID: UUID().uuidString) { result in
switch result {
case let .success(products):
Expand All @@ -89,12 +89,10 @@ final class IAPProvider: IIAPProvider {
return
}

let payment = SKPayment(product: product.product)

self.paymentProvider.add(payment: payment) { _, result in
self.purchaseProvider.purchase(product: StoreProduct(skProduct: product.product)) { result in
switch result {
case let .success(transaction):
completion(.success(PaymentTransaction(transaction)))
completion(.success(transaction))
case let .failure(error):
completion(.failure(error))
}
Expand All @@ -105,7 +103,7 @@ final class IAPProvider: IIAPProvider {
}
}

func purchase(productID: String) async throws -> PaymentTransaction {
func purchase(productID: String) async throws -> StoreTransaction {
try await withCheckedThrowingContinuation { continuation in
purchase(productID: productID) { result in
continuation.resume(with: result)
Expand Down Expand Up @@ -137,23 +135,15 @@ final class IAPProvider: IIAPProvider {
}

func finish(transaction: PaymentTransaction) {
paymentProvider.finish(transaction: transaction)
purchaseProvider.finish(transaction: transaction)
}

func addTransactionObserver(fallbackHandler: Closure<Result<PaymentTransaction, IAPError>>?) {
paymentProvider.set { _, result in
switch result {
case let .success(transaction):
fallbackHandler?(.success(PaymentTransaction(transaction)))
case let .failure(error):
fallbackHandler?(.failure(error))
}
}
paymentProvider.addTransactionObserver()
purchaseProvider.addTransactionObserver(fallbackHandler: fallbackHandler)
}

func removeTransactionObserver() {
paymentProvider.removeTransactionObserver()
purchaseProvider.removeTransactionObserver()
}

#if os(iOS) || VISION_OS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public protocol IIAPProvider {
/// - Parameters:
/// - productID: The product identifier.
/// - completion: The closure to be executed once the purchase is complete.
func purchase(productID: String, completion: @escaping Closure<Result<PaymentTransaction, IAPError>>)
func purchase(productID: String, completion: @escaping Closure<Result<StoreTransaction, IAPError>>)

/// Purchases a product with a given ID.
///
Expand All @@ -48,7 +48,7 @@ public protocol IIAPProvider {
/// - Throws: `IAPError.paymentNotAllowed` if user can't make payment.
///
/// - Returns: A payment transaction.
func purchase(productID: String) async throws -> PaymentTransaction
func purchase(productID: String) async throws -> StoreTransaction

/// Refreshes the receipt, representing the user's transactions with your app.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,28 @@

import Foundation

// typealias public PurchaseCompletionHandler = @MainActor @Sendable (
// StoreTransaction,
// Void
// )
public typealias PurchaseCompletionHandler = @MainActor @Sendable (Result<StoreTransaction, IAPError>) -> Void

// MARK: - IPurchaseProvider

protocol IPurchaseProvider {
func purchase(product: StoreProduct, completion: @escaping () -> Void)
/// Removes a finished (i.e. failed or completed) transaction from the queue.
/// Attempting to finish a purchasing transaction will throw an exception.
///
/// - Parameter transaction: An object in the payment queue.
func finish(transaction: PaymentTransaction)

/// Adds transaction observer to the payment queue.
/// The transactions array will only be synchronized with the server while the queue has observers.
///
/// - Note: This may require that the user authenticate.
func addTransactionObserver(fallbackHandler: Closure<Result<PaymentTransaction, IAPError>>?)

/// Removes transaction observer from the payment queue.
/// The transactions array will only be synchronized with the server while the queue has observers.
///
/// - Note: This may require that the user authenticate.
func removeTransactionObserver()

func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler)
}
Loading

0 comments on commit bd616d9

Please sign in to comment.