Skip to content

Commit

Permalink
[WIP] Implement Promotional Offers (#16)
Browse files Browse the repository at this point in the history
* Implement discount models

* Implement `PromotionalOffer` model

* Update tests

* Implement eligibility checking

* Add comments to the `SubscriptionEligibility` enumeration

* Implement `presentOfferCodeRedeemSheet(in:)` & `presentCodeRedemptionSheet()` methods

* Implement passing a configuration object

- Pass an `applicationUsername` property through the `Configuration` object
- Refactroing the package's dependencies

* Write code comments & fix typos

* Rename the `default` property to `shared`

* Update `CHANGELOG.md`

* Implement unit tests

* Implement tests

* Increase expectation duration for a test

* Update the documentation

* Fix flacky tests

* Add an integration test

* Add code comments
  • Loading branch information
nik3212 committed Jan 18, 2024
1 parent 7b4db09 commit 65f1cd1
Show file tree
Hide file tree
Showing 73 changed files with 2,730 additions and 505 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

## Added
- Implement Support for Promotional Offers
- Added in Pull Request [#16](https://github.com/space-code/flare/pull/16).

- Add additional badges to `README.md`
- Added in Pull Request [#15](https://github.com/space-code/flare/pull/15).

Expand Down Expand Up @@ -55,4 +58,4 @@ Released on 2023-01-20.

#### Added
- Initial release of Flare.
- Added by [Nikita Vasilev](https://github.com/nik3212).
- Added by [Nikita Vasilev](https://github.com/nik3212).
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ Flare is a framework written in Swift that makes it easy for you to work with in
## Features
- [x] Support Consumable & Non-Consumable Purchases
- [x] Support Subscription Purchase
- [x] Refresh Receipt
- [x] Complete Unit Test Coverage
- [x] Support Promotional & Introductory Offers
- [x] iOS, tvOS, watchOS, macOS, and visionOS compatible
- [x] Complete Unit & Integration Test Coverage

## Documentation
Check out [flare documentation](https://space-code.github.io/flare/documentation/flare/).
Expand Down
70 changes: 70 additions & 0 deletions Sources/Flare/Classes/DI/FlareDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Concurrency
import Foundation
import StoreKit

/// The package's dependencies.
final class FlareDependencies: IFlareDependencies {
// MARK: Internal

lazy var iapProvider: IIAPProvider = IAPProvider(
paymentQueue: SKPaymentQueue.default(),
productProvider: productProvider,
purchaseProvider: purchaseProvider,
receiptRefreshProvider: receiptRefreshProvider,
refundProvider: refundProvider,
eligibilityProvider: eligibilityProvider,
redeemCodeProvider: redeemCodeProvider
)

lazy var configurationProvider: IConfigurationProvider = ConfigurationProvider()

// MARK: Private

private var productProvider: IProductProvider {
ProductProvider(
dispatchQueueFactory: DispatchQueueFactory()
)
}

private var purchaseProvider: IPurchaseProvider {
PurchaseProvider(
paymentProvider: paymentProvider,
configurationProvider: configurationProvider
)
}

private var paymentProvider: IPaymentProvider {
PaymentProvider(
paymentQueue: SKPaymentQueue.default(),
dispatchQueueFactory: DispatchQueueFactory()
)
}

private var receiptRefreshProvider: IReceiptRefreshProvider {
ReceiptRefreshProvider(
dispatchQueueFactory: DispatchQueueFactory(),
receiptRefreshRequestFactory: ReceiptRefreshRequestFactory()
)
}

private var refundProvider: IRefundProvider {
RefundProvider(
systemInfoProvider: SystemInfoProvider()
)
}

private var eligibilityProvider: IEligibilityProvider {
EligibilityProvider()
}

private var redeemCodeProvider: IRedeemCodeProvider {
RedeemCodeProvider(systemInfoProvider: systemInfoProvider)
}

private lazy var systemInfoProvider: ISystemInfoProvider = SystemInfoProvider()
}
14 changes: 14 additions & 0 deletions Sources/Flare/Classes/DI/IFlareDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

/// The package's dependencies.
protocol IFlareDependencies {
/// The IAP provider.
var iapProvider: IIAPProvider { get }
/// The configuration provider.
var configurationProvider: IConfigurationProvider { get }
}
78 changes: 61 additions & 17 deletions Sources/Flare/Classes/Flare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,41 @@ import StoreKit

/// The class creates and manages in-app purchases.
public final class Flare {
// MARK: Properties

/// The in-app purchase provider.
private let iapProvider: IIAPProvider

/// The configuration provider.
private let configurationProvider: IConfigurationProvider

/// The singleton instance.
private static let flare: Flare = .init()

/// Returns a shared `Flare` object.
public static var shared: IFlare { flare }

// MARK: Initialization

/// Creates a new `Flare` instance.
///
/// - Parameter iapProvider: The in-app purchase provider.
init(iapProvider: IIAPProvider = IAPProvider()) {
self.iapProvider = iapProvider
/// - Parameters:
/// - dependencies: The package's dependencies.
/// - configurationProvider: The configuration provider.
init(dependencies: IFlareDependencies = FlareDependencies()) {
iapProvider = dependencies.iapProvider
configurationProvider = dependencies.configurationProvider
}

// MARK: Public

/// Returns a default `Flare` object.
public static let `default`: IFlare = Flare()

// MARK: Private

/// The in-app purchase provider.
private let iapProvider: IIAPProvider
/// Configures the Flare package with the provided configuration.
///
/// - Parameters:
/// - configuration: The configuration object containing settings for Flare.
public static func configure(with configuration: Configuration) {
flare.configurationProvider.configure(with: configuration)
}
}

// MARK: IFlare
Expand All @@ -44,13 +61,17 @@ extension Flare: IFlare {
try await iapProvider.fetch(productIDs: productIDs)
}

public func purchase(product: StoreProduct, completion: @escaping Closure<Result<StoreTransaction, IAPError>>) {
public func purchase(
product: StoreProduct,
promotionalOffer: PromotionalOffer?,
completion: @escaping Closure<Result<StoreTransaction, IAPError>>
) {
guard iapProvider.canMakePayments else {
completion(.failure(.paymentNotAllowed))
return
}

iapProvider.purchase(product: product) { result in
iapProvider.purchase(product: product, promotionalOffer: promotionalOffer) { result in
switch result {
case let .success(transaction):
completion(.success(transaction))
Expand All @@ -60,31 +81,33 @@ extension Flare: IFlare {
}
}

public func purchase(product: StoreProduct) async throws -> StoreTransaction {
public func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction {
guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed }
return try await iapProvider.purchase(product: product)
return try await iapProvider.purchase(product: product, promotionalOffer: promotionalOffer)
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
public func purchase(
product: StoreProduct,
options: Set<StoreKit.Product.PurchaseOption>,
promotionalOffer: PromotionalOffer?,
completion: @escaping SendableClosure<Result<StoreTransaction, IAPError>>
) {
guard iapProvider.canMakePayments else {
completion(.failure(.paymentNotAllowed))
return
}
iapProvider.purchase(product: product, options: options, completion: completion)
iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer, completion: completion)
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
public func purchase(
product: StoreProduct,
options: Set<StoreKit.Product.PurchaseOption>
options: Set<StoreKit.Product.PurchaseOption>,
promotionalOffer: PromotionalOffer?
) async throws -> StoreTransaction {
guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed }
return try await iapProvider.purchase(product: product, options: options)
return try await iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer)
}

public func receipt(completion: @escaping Closure<Result<String, IAPError>>) {
Expand Down Expand Up @@ -114,6 +137,11 @@ extension Flare: IFlare {
iapProvider.removeTransactionObserver()
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
public func checkEligibility(productIDs: Set<String>) async throws -> [String: SubscriptionEligibility] {
try await iapProvider.checkEligibility(productIDs: productIDs)
}

#if os(iOS) || VISION_OS
@available(iOS 15.0, *)
@available(macOS, unavailable)
Expand All @@ -122,5 +150,21 @@ extension Flare: IFlare {
public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
try await iapProvider.beginRefundRequest(productID: productID)
}

@available(iOS 14.0, *)
@available(macOS, unavailable)
@available(watchOS, unavailable)
@available(tvOS, unavailable)
public func presentCodeRedemptionSheet() {
iapProvider.presentCodeRedemptionSheet()
}

@available(iOS 16.0, *)
@available(macOS, unavailable)
@available(watchOS, unavailable)
@available(tvOS, unavailable)
public func presentOfferCodeRedeemSheet() async throws {
try await iapProvider.presentOfferCodeRedeemSheet()
}
#endif
}
24 changes: 24 additions & 0 deletions Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

/// Protocol for managing `UserDefaults` operations.
protocol IUserDefaults {
/// Sets a `Codable` value in `UserDefaults` for a given key.
///
/// - Parameters:
/// - key: The key to associate with the Codable value.
///
/// - codable: The Codable value to be stored.
func set<T: Codable>(key: String, codable: T)

/// Retrieves a `Codable` value from `UserDefaults` for a given key.
///
/// - Parameter key: The key associated with the desired Codable value.
///
/// - Returns: The Codable value stored for the given key, or nil if not found.
func get<T: Codable>(key: String) -> T?
}
19 changes: 19 additions & 0 deletions Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

extension UserDefaults: IUserDefaults {
func set<T: Codable>(key: String, codable: T) {
guard let value = try? JSONEncoder().encode(codable) else { return }
set(value, forKey: key)
}

func get<T: Codable>(key: String) -> T? {
let data = object(forKey: key) as? Data
guard let data = data, let value = try? JSONDecoder().decode(T.self, from: data) else { return nil }
return value
}
}
6 changes: 6 additions & 0 deletions Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,10 @@ public protocol PaymentQueue: AnyObject {
/// Remove a finished (i.e. failed or completed) transaction from the queue.
/// Attempting to finish a purchasing transaction will throw an exception.
func finishTransaction(_ transaction: SKPaymentTransaction)

#if os(iOS) || VISION_OS
// Call this method to have StoreKit present a sheet enabling the user to redeem codes provided by your app.
@available(iOS 14.0, *)
func presentCodeRedemptionSheet()
#endif
}
Loading

0 comments on commit 65f1cd1

Please sign in to comment.