Skip to content

Commit

Permalink
Implement auto-detection of active subscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
nik3212 committed Apr 20, 2024
1 parent 6f3af02 commit d98ba4f
Show file tree
Hide file tree
Showing 53 changed files with 931 additions and 67 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import struct StoreKit.Product

// MARK: - ISubscriptionInfoStatus

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
extension Product.SubscriptionInfo.Status: ISubscriptionInfoStatus {
var renewalState: RenewalState {
RenewalState(self.state)
}

Check warning on line 14 in Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift#L12-L14

Added lines #L12 - L14 were not covered by tests

var subscriptionRenewalInfo: VerificationResult<RenewalInfo> {
switch self.renewalInfo {
case let .verified(renewalInfo):
return .verified(.init(renewalInfo: renewalInfo))
case let .unverified(renewalInfo, error):
return .unverified(.init(renewalInfo: renewalInfo), error)
}
}

Check warning on line 23 in Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift#L16-L23

Added lines #L16 - L23 were not covered by tests
}
2 changes: 1 addition & 1 deletion Sources/Flare/Classes/Flare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ extension Flare: IFlare {
await iapProvider.finish(transaction: transaction)
}

public func addTransactionObserver(fallbackHandler: Closure<Result<PaymentTransaction, IAPError>>?) {
public func addTransactionObserver(fallbackHandler: Closure<Result<StoreTransaction, IAPError>>?) {
iapProvider.addTransactionObserver(fallbackHandler: fallbackHandler)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Flare/Classes/IFlare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public protocol IFlare {
/// 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>>?)
func addTransactionObserver(fallbackHandler: Closure<Result<StoreTransaction, IAPError>>?)

/// Removes transaction observer from the payment queue.
/// The transactions array will only be synchronized with the server while the queue has observers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ protocol ITransactionListener: Sendable {
/// - Note: Available on iOS 15.0+, tvOS 15.0+, macOS 12.0+, watchOS 8.0+.
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func handle(purchaseResult: StoreKit.Product.PurchaseResult) async throws -> StoreTransaction?

func set(delegate: TransactionListenerDelegate) async
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,33 @@ actor TransactionListener {
private let updates: AsyncStream<TransactionResult>
private var task: Task<Void, Never>?

private weak var delegate: TransactionListenerDelegate?

// MARK: Initialization

init<S: AsyncSequence>(updates: S) where S.Element == TransactionResult {
init<S: AsyncSequence>(delegate: TransactionListenerDelegate? = nil, updates: S) where S.Element == TransactionResult {
self.delegate = delegate
self.updates = updates.toAsyncStream()
}

// MARK: Private

private func handle(
transactionResult: TransactionResult,
fromTransactionUpdate _: Bool
fromTransactionUpdate: Bool
) async throws -> StoreTransaction {
switch transactionResult {
case let .verified(transaction):
return StoreTransaction(
let transaction = StoreTransaction(
transaction: transaction,
jwtRepresentation: transactionResult.jwsRepresentation
)

if fromTransactionUpdate {
delegate?.transactionListener(self, transactionDidUpdate: .success(transaction))
}

return transaction
case let .unverified(transaction, verificationError):
Logger.info(
message: L10n.Purchase.transactionUnverified(
Expand All @@ -45,9 +54,15 @@ actor TransactionListener {
)
)

throw IAPError.verification(
error: .unverified(productID: transaction.productID, error: verificationError)
let error = IAPError.verification(
error: .init(verificationError)

Check warning on line 58 in Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift#L57-L58

Added lines #L57 - L58 were not covered by tests
)

if fromTransactionUpdate {
delegate?.transactionListener(self, transactionDidUpdate: .failure(error))
}

throw error

Check warning on line 65 in Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift#L60-L65

Added lines #L60 - L65 were not covered by tests
}
}
}
Expand All @@ -56,6 +71,10 @@ actor TransactionListener {

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
extension TransactionListener: ITransactionListener {
func set(delegate: TransactionListenerDelegate) {
self.delegate = delegate
}

Check warning on line 76 in Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift#L74-L76

Added lines #L74 - L76 were not covered by tests

func listenForTransaction() async {
task?.cancel()
task = Task(priority: .utility) { [weak self] in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

protocol TransactionListenerDelegate: AnyObject {
func transactionListener(
_ transactionListener: ITransactionListener,
transactionDidUpdate result: Result<StoreTransaction, IAPError>
)
}
37 changes: 37 additions & 0 deletions Sources/Flare/Classes/Models/ExpirationReason.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation
import StoreKit

// MARK: - ExpirationReason

public enum ExpirationReason {
case autoRenewDisabled
case billingError
case didNotConsentToPriceIncrease
case productUnavailable
case unknown
}

extension ExpirationReason {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
init(expirationReason: Product.SubscriptionInfo.RenewalInfo.ExpirationReason) {
switch expirationReason {
case .autoRenewDisabled:
self = .autoRenewDisabled
case .billingError:
self = .billingError
case .didNotConsentToPriceIncrease:
self = .didNotConsentToPriceIncrease
case .productUnavailable:
self = .productUnavailable
case .unknown:
self = .unknown
default:
self = .unknown
}
}

Check warning on line 36 in Sources/Flare/Classes/Models/ExpirationReason.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/ExpirationReason.swift#L21-L36

Added lines #L21 - L36 were not covered by tests
}
46 changes: 46 additions & 0 deletions Sources/Flare/Classes/Models/Internal/Protocols/IRenewalInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation
import StoreKit

public protocol IRenewalInfo {
/// The JSON representation of the renewal information.
var jsonRepresentation: Data { get }

/// The original transaction identifier for the subscription group.
var originalTransactionID: UInt64 { get }

/// The currently active product identifier, or the most recently active product identifier if the
/// subscription is expired.
var currentProductID: String { get }

/// Whether the subscription will auto renew at the end of the current billing period.
var willAutoRenew: Bool { get }

/// The product identifier the subscription will auto renew to at the end of the current billing period.
///
/// If the user disabled auto renewing, this property will be `nil`.
var autoRenewPreference: String? { get }

/// The reason the subscription expired.
var expirationReason: ExpirationReason? { get }

/// The status of a price increase for the user.
var priceIncreaseStatus: PriceIncreaseStatus { get }

/// Whether the subscription is in a billing retry period.
var isInBillingRetry: Bool { get }

/// The date the billing grace period will expire.
var gracePeriodExpirationDate: Date? { get }

/// Identifies the offer that will be applied to the next billing period.
///
/// If `offerType` is `promotional`, this will be the offer identifier. If `offerType` is
/// `code`, this will be the offer code reference name. This will be `nil` for `introductory`
/// offers and if there will be no offer applied for the next billing period.
var offerID: String? { get }
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ protocol ISKProduct {

/// The subscription group identifier.
var subscriptionGroupIdentifier: String? { get }

/// The subscription info.
var subscription: SubscriptionInfo? { get }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

protocol ISubscriptionInfo {
var subscriptionStatus: [SubscriptionInfoStatus] { get async throws }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import Foundation

protocol ISubscriptionInfoStatus {
var renewalState: RenewalState { get }
var subscriptionRenewalInfo: VerificationResult<RenewalInfo> { get }
}
4 changes: 4 additions & 0 deletions Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@ extension SK1StoreProduct: ISKProduct {
var subscriptionGroupIdentifier: String? {
product.subscriptionGroupIdentifier
}

var subscription: SubscriptionInfo? {
nil
}

Check warning on line 83 in Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift#L81-L83

Added lines #L81 - L83 were not covered by tests
}
69 changes: 69 additions & 0 deletions Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import StoreKit

// MARK: - SK2RenewalInfo

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
struct SK2RenewalInfo {
// MARK: Properties

let underlyingRenewalInfo: Product.SubscriptionInfo.RenewalInfo

// MARK: Initialization

init(underlyingRenewalInfo: Product.SubscriptionInfo.RenewalInfo) {
self.underlyingRenewalInfo = underlyingRenewalInfo
}

Check warning on line 20 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L18-L20

Added lines #L18 - L20 were not covered by tests
}

// MARK: IRenewalInfo

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
extension SK2RenewalInfo: IRenewalInfo {
var jsonRepresentation: Data {
underlyingRenewalInfo.jsonRepresentation
}

Check warning on line 29 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L27-L29

Added lines #L27 - L29 were not covered by tests

var originalTransactionID: UInt64 {
underlyingRenewalInfo.originalTransactionID
}

Check warning on line 33 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L31-L33

Added lines #L31 - L33 were not covered by tests

var willAutoRenew: Bool {
underlyingRenewalInfo.willAutoRenew
}

Check warning on line 37 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L35-L37

Added lines #L35 - L37 were not covered by tests

var autoRenewPreference: String? {
underlyingRenewalInfo.autoRenewPreference
}

Check warning on line 41 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L39-L41

Added lines #L39 - L41 were not covered by tests

var isInBillingRetry: Bool {
underlyingRenewalInfo.isInBillingRetry
}

Check warning on line 45 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L43-L45

Added lines #L43 - L45 were not covered by tests

var gracePeriodExpirationDate: Date? {
underlyingRenewalInfo.gracePeriodExpirationDate
}

Check warning on line 49 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L47-L49

Added lines #L47 - L49 were not covered by tests

var offerID: String? {
underlyingRenewalInfo.offerID
}

Check warning on line 53 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L51-L53

Added lines #L51 - L53 were not covered by tests

var currentProductID: String {
underlyingRenewalInfo.currentProductID
}

Check warning on line 57 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L55-L57

Added lines #L55 - L57 were not covered by tests

var expirationReason: ExpirationReason? {
guard let expirationReason = self.underlyingRenewalInfo.expirationReason else {
return nil
}
return ExpirationReason(expirationReason: expirationReason)
}

Check warning on line 64 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L59-L64

Added lines #L59 - L64 were not covered by tests

var priceIncreaseStatus: PriceIncreaseStatus {
PriceIncreaseStatus(underlyingRenewalInfo.priceIncreaseStatus)
}

Check warning on line 68 in Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift#L66-L68

Added lines #L66 - L68 were not covered by tests
}
7 changes: 7 additions & 0 deletions Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,11 @@ extension SK2StoreProduct: ISKProduct {
var subscriptionGroupIdentifier: String? {
product.subscription?.subscriptionGroupID
}

var subscription: SubscriptionInfo? {
guard let subscription = product.subscription else {
return nil
}
return SubscriptionInfo(subscriptionInfo: subscription)
}

Check warning on line 94 in Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift#L89-L94

Added lines #L89 - L94 were not covered by tests
}
32 changes: 32 additions & 0 deletions Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Flare
// Copyright © 2024 Space Code. All rights reserved.
//

import StoreKit

// MARK: - SK2SubscriptionInfo

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
struct SK2SubscriptionInfo {
// MARK: Properties

private let underlyingInfo: Product.SubscriptionInfo

// MARK: Initialization

init(underlyingInfo: Product.SubscriptionInfo) {
self.underlyingInfo = underlyingInfo
}

Check warning on line 20 in Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift#L18-L20

Added lines #L18 - L20 were not covered by tests
}

// MARK: ISubscriptionInfo

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
extension SK2SubscriptionInfo: ISubscriptionInfo {
var subscriptionStatus: [SubscriptionInfoStatus] {
get async throws {
try await self.underlyingInfo.status.map { SubscriptionInfoStatus(underlyingStatus: $0) }
}

Check warning on line 30 in Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift#L28-L30

Added lines #L28 - L30 were not covered by tests
}
}

0 comments on commit d98ba4f

Please sign in to comment.