Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds subscriptions to CustomerInfo #4508

Merged
merged 19 commits into from
Dec 4, 2024
Merged
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,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 @@ -1536,6 +1537,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 @@ -4767,6 +4769,7 @@
B3A36AAC26BC76230059EDEA /* Identity */ = {
isa = PBXGroup;
children = (
35DE0DB52CEF9E8C00EB83E9 /* SubscriptionInfo.swift */,
A56F9AB026990E9200AFC48F /* CustomerInfo.swift */,
57F3C10429B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift */,
4F15B4A02A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift */,
Expand Down Expand Up @@ -5733,6 +5736,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 */,
A525BF4B26C320D100C354C4 /* SubscriberAttributesManager.swift in Sources */,
2D1015DA275959840086173F /* StoreTransaction.swift in Sources */,
Expand Down
39 changes: 35 additions & 4 deletions Sources/Identity/CustomerInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

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 +29,10 @@ 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 +93,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 +151,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 +219,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 {
vegaro marked this conversation as resolved.
Show resolved Hide resolved

/// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small concern but I'm wondering if we should have a VerificationResult (from trusted entitlements) here as well, or maybe move the existing one in EntitlementInfos up to the CustomerInfo entity... otherwise some devs might use this without checking trusted entitlements...


/// 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking that we know this is always available, and having it optional was being too defensive?

Copy link
Contributor Author

@vegaro vegaro Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked in the backend and it's not optional there

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