Skip to content

Commit

Permalink
Adds subscriptions to CustomerInfo (#4508)
Browse files Browse the repository at this point in the history
* adds SubscriptionInfo

* fix nullabilities

* purchaseDate is not nullable

* add isActive

* add store

* add the product identifier

* add willRenew

* private extension

* made storeTransactionId nil

* fix tests and docs

* fix tests

* fix more tests

* fix compilation

* fix test

* API tests and other fixes

* Update Sources/Purchasing/NonSubscriptionTransaction.swift

Co-authored-by: Andy Boedo <andresboedo@gmail.com>

* use ProductIdentifier and subscriptionsByProductIdentifier

* fix lint

* disable file_length

---------

Co-authored-by: Andy Boedo <andresboedo@gmail.com>
  • Loading branch information
vegaro and aboedo authored Dec 4, 2024
1 parent 601c6d1 commit 658782b
Show file tree
Hide file tree
Showing 18 changed files with 513 additions and 66 deletions.
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@
35D83300262FAD8000E60AC5 /* ETagManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D832FF262FAD8000E60AC5 /* ETagManagerTests.swift */; };
35D8330A262FBA9A00E60AC5 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357D16038F07915D7825D /* MockUserDefaults.swift */; };
35D83312262FBD4200E60AC5 /* MockETagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D83311262FBD4200E60AC5 /* MockETagManager.swift */; };
35DE0DB62CEF9E8F00EB83E9 /* SubscriptionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35DE0DB52CEF9E8C00EB83E9 /* SubscriptionInfo.swift */; };
35E840CC270FB70D00899AE2 /* ManageSubscriptionsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35E840C5270FB47C00899AE2 /* ManageSubscriptionsHelper.swift */; };
35E840CE2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35E840CD2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift */; };
35F249CA2C493D970058993A /* LoadPromotionalOfferUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F249C92C493D970058993A /* LoadPromotionalOfferUseCase.swift */; };
Expand Down Expand Up @@ -1585,6 +1586,7 @@
35D832F3262E606500E60AC5 /* HTTPResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponse.swift; sourceTree = "<group>"; };
35D832FF262FAD8000E60AC5 /* ETagManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ETagManagerTests.swift; sourceTree = "<group>"; };
35D83311262FBD4200E60AC5 /* MockETagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockETagManager.swift; sourceTree = "<group>"; };
35DE0DB52CEF9E8C00EB83E9 /* SubscriptionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInfo.swift; sourceTree = "<group>"; };
35E1CE1F26E022C20008560A /* TrialOrIntroPriceEligibilityCheckerSK1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialOrIntroPriceEligibilityCheckerSK1Tests.swift; sourceTree = "<group>"; };
35E840C5270FB47C00899AE2 /* ManageSubscriptionsHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManageSubscriptionsHelper.swift; sourceTree = "<group>"; };
35E840CD2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockManageSubscriptionsHelper.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4893,6 +4895,7 @@
B3A36AAC26BC76230059EDEA /* Identity */ = {
isa = PBXGroup;
children = (
35DE0DB52CEF9E8C00EB83E9 /* SubscriptionInfo.swift */,
A56F9AB026990E9200AFC48F /* CustomerInfo.swift */,
57F3C10429B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift */,
4F15B4A02A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift */,
Expand Down Expand Up @@ -5875,6 +5878,7 @@
FD43D2FC2C41864000077235 /* TimeInterval+Extensions.swift in Sources */,
4F7DBFBD2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift in Sources */,
5766AB4728401B8400FA6091 /* PackageType.swift in Sources */,
35DE0DB62CEF9E8F00EB83E9 /* SubscriptionInfo.swift in Sources */,
B3F3E8DA277158FE0047A5B9 /* DNSChecker.swift in Sources */,
1EB697862CD0ED0B003000FC /* WebPurchaseRedemptionResult.swift in Sources */,
A525BF4B26C320D100C354C4 /* SubscriberAttributesManager.swift in Sources */,
Expand Down
42 changes: 38 additions & 4 deletions Sources/Identity/CustomerInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@
// Created by Madeline Beyl on 7/9/21.
//

// swiftlint:disable file_length
import Foundation

/**
An identifier used to identify a product.
*/
public typealias ProductIdentifier = String

/**
A container for the most recent customer info returned from `Purchases`.
These objects are non-mutable and do not update automatically.
Expand All @@ -24,10 +30,12 @@ import Foundation
@objc public let entitlements: EntitlementInfos

/// All *subscription* product identifiers with expiration dates in the future.
@objc public var activeSubscriptions: Set<String> { self.activeKeys(dates: self.expirationDatesByProductId) }
@objc public var activeSubscriptions: Set<ProductIdentifier> {
self.activeKeys(dates: self.expirationDatesByProductId)
}

/// All product identifiers purchases by the user regardless of expiration.
@objc public let allPurchasedProductIdentifiers: Set<String>
@objc public let allPurchasedProductIdentifiers: Set<ProductIdentifier>

/// Returns the latest expiration date of all products, nil if there are none.
@objc public var latestExpirationDate: Date? {
Expand Down Expand Up @@ -88,17 +96,20 @@ import Foundation
*/
@objc public let originalApplicationVersion: String?

/// Dictionary of all subscription product identifiers and their subscription info
@objc public let subscriptionsByProductIdentifier: [ProductIdentifier: SubscriptionInfo]

/// Get the expiration date for a given product identifier. You should use Entitlements though!
/// - Parameter productIdentifier: Product identifier for product
/// - Returns: The expiration date for `productIdentifier`, `nil` if product never purchased
@objc public func expirationDate(forProductIdentifier productIdentifier: String) -> Date? {
@objc public func expirationDate(forProductIdentifier productIdentifier: ProductIdentifier) -> Date? {
return expirationDatesByProductId[productIdentifier] ?? nil
}

/// Get the latest purchase or renewal date for a given product identifier. You should use Entitlements though!
/// - Parameter productIdentifier: Product identifier for subscription product
/// - Returns: The purchase date for `productIdentifier`, `nil` if product never purchased
@objc public func purchaseDate(forProductIdentifier productIdentifier: String) -> Date? {
@objc public func purchaseDate(forProductIdentifier productIdentifier: ProductIdentifier) -> Date? {
return purchaseDatesByProductId[productIdentifier] ?? nil
}

Expand Down Expand Up @@ -143,13 +154,16 @@ import Foundation

let verificationResult = self.entitlements.verification.debugDescription

let subscriptionsDescription = self.subscriptionsByProductIdentifier.mapValues { $0.description }

return """
<\(String(describing: CustomerInfo.self)):
originalApplicationVersion=\(self.originalApplicationVersion ?? ""),
latestExpirationDate=\(String(describing: self.latestExpirationDate)),
activeEntitlements=\(activeEntitlementsDescription),
activeSubscriptions=\(activeSubsDescription),
nonSubscriptions=\(self.nonSubscriptions),
subscriptions=\(subscriptionsDescription),
requestDate=\(String(describing: self.requestDate)),
firstSeen=\(String(describing: self.firstSeen)),
originalAppUserId=\(self.originalAppUserId),
Expand Down Expand Up @@ -208,6 +222,26 @@ import Foundation
self.purchaseDatesByProductId = Self.extractPurchaseDates(subscriber)
self.allPurchasedProductIdentifiers = Set(self.expirationDatesByProductId.keys)
.union(self.nonSubscriptions.map { $0.productIdentifier })

self.subscriptionsByProductIdentifier =
Dictionary(uniqueKeysWithValues: subscriber.subscriptions.map { (key, subscriptionData) in
(key, SubscriptionInfo(
productIdentifier: key,
purchaseDate: subscriptionData.purchaseDate,
originalPurchaseDate: subscriptionData.originalPurchaseDate,
expiresDate: subscriptionData.expiresDate,
store: subscriptionData.store,
isSandbox: subscriptionData.isSandbox,
unsubscribeDetectedAt: subscriptionData.unsubscribeDetectedAt,
billingIssuesDetectedAt: subscriptionData.billingIssuesDetectedAt,
gracePeriodExpiresDate: subscriptionData.gracePeriodExpiresDate,
ownershipType: subscriptionData.ownershipType,
periodType: subscriptionData.periodType,
refundedAt: subscriptionData.refundedAt,
storeTransactionId: subscriptionData.storeTransactionId,
requestDate: response.requestDate
))
})
}

private let expirationDatesByProductId: [String: Date?]
Expand Down
134 changes: 134 additions & 0 deletions Sources/Identity/SubscriptionInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// SubscriptionInfo.swift
//
// Created by Cesar de la Vega on 21/11/24.

import Foundation

/// Subscription purchases of the Customer
@objc(RCSubscriptionInfo) public final class SubscriptionInfo: NSObject {

/// The product identifier.
@objc public let productIdentifier: ProductIdentifier

/// Date when the last subscription period started.
@objc public let purchaseDate: Date

/// Date when this subscription first started. This property does not update with renewals.
/// This property also does not update for product changes within a subscription group or
/// resubscriptions by lapsed subscribers.
@objc public let originalPurchaseDate: Date?

/// Date when the subscription expires/expired
@objc public let expiresDate: Date?

/// Store where the subscription was purchased.
@objc public let store: Store

/// Whether or not the purchase was made in sandbox mode.
@objc public let isSandbox: Bool

/// Date when RevenueCat detected that auto-renewal was turned off for this subsription.
/// Note the subscription may still be active, check the ``expiresDate`` attribute.
@objc public let unsubscribeDetectedAt: Date?

/// Date when RevenueCat detected any billing issues with this subscription.
/// If and when the billing issue gets resolved, this field is set to nil.
/// Note the subscription may still be active, check the ``expiresDate`` attribute.
@objc public let billingIssuesDetectedAt: Date?

/// Date when any grace period for this subscription expires/expired.
/// nil if the customer has never been in a grace period.
@objc public let gracePeriodExpiresDate: Date?

/// How the Customer received access to this subscription:
/// - ``PurchaseOwnershipType/purchased``: The customer bought the subscription.
/// - ``PurchaseOwnershipType/familyShared``: The Customer has access to the product via their family.
@objc public let ownershipType: PurchaseOwnershipType

/// Type of the current subscription period:
/// - ``PeriodType/normal``: The product is in a normal period (default)
/// - ``PeriodType/trial``: The product is in a free trial period
/// - ``PeriodType/intro``: The product is in an introductory pricing period
@objc public let periodType: PeriodType

/// Date when RevenueCat detected a refund of this subscription.
@objc public let refundedAt: Date?

/// The transaction id in the store of the subscription.
@objc public let storeTransactionId: String?

/// Whether the subscription is currently active.
@objc public let isActive: Bool

/// Whether the subscription will renew at the next billing period.
@objc public let willRenew: Bool

init(productIdentifier: String,
purchaseDate: Date,
originalPurchaseDate: Date?,
expiresDate: Date?,
store: Store,
isSandbox: Bool,
unsubscribeDetectedAt: Date?,
billingIssuesDetectedAt: Date?,
gracePeriodExpiresDate: Date?,
ownershipType: PurchaseOwnershipType,
periodType: PeriodType,
refundedAt: Date?,
storeTransactionId: String?,
requestDate: Date) {
self.productIdentifier = productIdentifier
self.purchaseDate = purchaseDate
self.originalPurchaseDate = originalPurchaseDate
self.expiresDate = expiresDate
self.store = store
self.isSandbox = isSandbox
self.unsubscribeDetectedAt = unsubscribeDetectedAt
self.billingIssuesDetectedAt = billingIssuesDetectedAt
self.gracePeriodExpiresDate = gracePeriodExpiresDate
self.ownershipType = ownershipType
self.periodType = periodType
self.refundedAt = refundedAt
self.storeTransactionId = storeTransactionId
self.isActive = CustomerInfo.isDateActive(expirationDate: expiresDate, for: requestDate)
self.willRenew = EntitlementInfo.willRenewWithExpirationDate(expirationDate: expiresDate,
store: store,
unsubscribeDetectedAt: unsubscribeDetectedAt,
billingIssueDetectedAt: billingIssuesDetectedAt)

super.init()
}

public override var description: String {
return """
SubscriptionInfo {
purchaseDate: \(String(describing: purchaseDate)),
originalPurchaseDate: \(String(describing: originalPurchaseDate)),
expiresDate: \(String(describing: expiresDate)),
store: \(store),
isSandbox: \(isSandbox),
unsubscribeDetectedAt: \(String(describing: unsubscribeDetectedAt)),
billingIssuesDetectedAt: \(String(describing: billingIssuesDetectedAt)),
gracePeriodExpiresDate: \(String(describing: gracePeriodExpiresDate)),
ownershipType: \(ownershipType),
periodType: \(String(describing: periodType)),
refundedAt: \(String(describing: refundedAt)),
storeTransactionId: \(String(describing: storeTransactionId)),
isActive: \(isActive),
willRenew: \(willRenew)
}
"""
}

}

extension SubscriptionInfo: Sendable {}
15 changes: 10 additions & 5 deletions Sources/Networking/Responses/CustomerInfoResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ extension CustomerInfoResponse {

@IgnoreDecodeErrors<PeriodType>
var periodType: PeriodType
var purchaseDate: Date?
var purchaseDate: Date
var originalPurchaseDate: Date?
var expiresDate: Date?
@IgnoreDecodeErrors<Store>
Expand All @@ -62,12 +62,15 @@ extension CustomerInfoResponse {
var ownershipType: PurchaseOwnershipType
var productPlanIdentifier: String?
var metadata: [String: String]?
var gracePeriodExpiresDate: Date?
var refundedAt: Date?
var storeTransactionId: String?

}

struct Transaction {

var purchaseDate: Date?
var purchaseDate: Date
var originalPurchaseDate: Date?
var transactionIdentifier: String?
var storeTransactionIdentifier: String?
Expand Down Expand Up @@ -174,7 +177,7 @@ extension CustomerInfoResponse.Subscriber {
extension CustomerInfoResponse.Transaction {

init(
purchaseDate: Date?,
purchaseDate: Date,
originalPurchaseDate: Date?,
transactionIdentifier: String?,
storeTransactionIdentifier: String?,
Expand Down Expand Up @@ -202,14 +205,15 @@ extension CustomerInfoResponse.Subscription {

init(
periodType: PeriodType = .defaultValue,
purchaseDate: Date? = nil,
purchaseDate: Date,
originalPurchaseDate: Date? = nil,
expiresDate: Date? = nil,
store: Store = .defaultValue,
isSandbox: Bool,
unsubscribeDetectedAt: Date? = nil,
billingIssuesDetectedAt: Date? = nil,
ownershipType: PurchaseOwnershipType = .defaultValue
ownershipType: PurchaseOwnershipType = .defaultValue,
storeTransactionId: String? = nil
) {
self.periodType = periodType
self.purchaseDate = purchaseDate
Expand All @@ -220,6 +224,7 @@ extension CustomerInfoResponse.Subscription {
self.unsubscribeDetectedAt = unsubscribeDetectedAt
self.billingIssuesDetectedAt = billingIssuesDetectedAt
self.ownershipType = ownershipType
self.storeTransactionId = storeTransactionId
}

var asTransaction: CustomerInfoResponse.Transaction {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Purchasing/EntitlementInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ public extension EntitlementInfo {

// MARK: - Internal

private extension EntitlementInfo {
extension EntitlementInfo {

static func willRenewWithExpirationDate(expirationDate: Date?,
store: Store,
Expand Down
11 changes: 8 additions & 3 deletions Sources/Purchasing/NonSubscriptionTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,24 @@ public final class NonSubscriptionTransaction: NSObject {
/// The unique identifier for the transaction created by the Store.
@objc public let storeTransactionIdentifier: String

/**
* The ``Store`` where this transaction was performed.
*/
@objc public let store: Store

init?(with transaction: CustomerInfoResponse.Transaction, productID: String) {
guard let transactionIdentifier = transaction.transactionIdentifier,
let storeTransactionIdentifier = transaction.storeTransactionIdentifier,
let purchaseDate = transaction.purchaseDate else {
let storeTransactionIdentifier = transaction.storeTransactionIdentifier else {
Logger.error("Couldn't initialize NonSubscriptionTransaction. " +
"Reason: missing data: \(transaction).")
return nil
}

self.transactionIdentifier = transactionIdentifier
self.storeTransactionIdentifier = storeTransactionIdentifier
self.purchaseDate = purchaseDate
self.purchaseDate = transaction.purchaseDate
self.productIdentifier = productID
self.store = transaction.store
}

public override var description: String {
Expand Down
Loading

0 comments on commit 658782b

Please sign in to comment.