Skip to content

Commit 26d75ac

Browse files
committed
Implement automatic trial conversion detection + add productID param
1 parent 8a79b44 commit 26d75ac

File tree

2 files changed

+155
-17
lines changed

2 files changed

+155
-17
lines changed

Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ extension TelemetryDeck {
5151
) {
5252
self.internalSignal(
5353
"TelemetryDeck.Purchase.freeTrialStarted",
54-
parameters: self.purchaseParameters(transaction: transaction).merging(parameters) { $1 },
54+
parameters: transaction.purchaseParameters().merging(parameters) { $1 },
5555
customUserID: customUserID
5656
)
57+
58+
TrialConversionTracker.shared.freeTrialStarted(transaction: transaction)
5759
}
5860

5961
private static func reportPaidPurchase(
@@ -63,63 +65,67 @@ extension TelemetryDeck {
6365
) {
6466
self.internalSignal(
6567
"TelemetryDeck.Purchase.completed",
66-
parameters: self.purchaseParameters(transaction: transaction).merging(parameters) { $1 },
67-
floatValue: self.calculatePriceInUSD(transaction: transaction),
68+
parameters: transaction.purchaseParameters().merging(parameters) { $1 },
69+
floatValue: transaction.priceInUSD(),
6870
customUserID: customUserID
6971
)
7072
}
73+
}
7174

72-
private static func purchaseParameters(transaction: StoreKit.Transaction) -> [String: String] {
75+
@available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *)
76+
extension Transaction {
77+
func purchaseParameters() -> [String: String] {
7378
let countryCode: String
7479
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
75-
countryCode = transaction.storefront.countryCode
80+
countryCode = self.storefront.countryCode
7681
} else {
7782
#if os(visionOS)
7883
countryCode = "US"
7984
#else
80-
countryCode = transaction.storefrontCountryCode
85+
countryCode = self.storefrontCountryCode
8186
#endif
8287
}
8388

8489
var purchaseParameters: [String: String] = [
85-
"TelemetryDeck.Purchase.type": transaction.subscriptionGroupID != nil ? "subscription" : "one-time-purchase",
90+
"TelemetryDeck.Purchase.type": self.subscriptionGroupID != nil ? "subscription" : "one-time-purchase",
8691
"TelemetryDeck.Purchase.countryCode": countryCode,
92+
"TelemetryDeck.Purchase.productID": self.productID,
8793
]
8894

8995
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
90-
if let currencyCode = transaction.currency?.identifier {
96+
if let currencyCode = self.currency?.identifier {
9197
purchaseParameters["TelemetryDeck.Purchase.currencyCode"] = currencyCode
9298
}
9399
} else {
94-
if let currencyCode = transaction.currencyCode {
100+
if let currencyCode = self.currencyCode {
95101
purchaseParameters["TelemetryDeck.Purchase.currencyCode"] = currencyCode
96102
}
97103
}
98104

99105
return purchaseParameters
100106
}
101107

102-
private static func calculatePriceInUSD(transaction: StoreKit.Transaction) -> Double {
103-
let priceValueInNativeCurrency = NSDecimalNumber(decimal: transaction.price ?? Decimal()).doubleValue
108+
func priceInUSD() -> Double {
109+
let priceValueInNativeCurrency = NSDecimalNumber(decimal: self.price ?? Decimal()).doubleValue
104110
let priceValueInUSD: Double
105111

106112
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
107-
if transaction.currency?.identifier == "USD" {
113+
if self.currency?.identifier == "USD" {
108114
priceValueInUSD = priceValueInNativeCurrency
109115
} else if
110-
let currencyCode = transaction.currency?.identifier,
111-
let oneUSDExchangeRate = self.currencyCodeToOneUSDExchangeRate[currencyCode]
116+
let currencyCode = self.currency?.identifier,
117+
let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode]
112118
{
113119
priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate
114120
} else {
115121
priceValueInUSD = 0
116122
}
117123
} else {
118-
if transaction.currencyCode == "USD" {
124+
if self.currencyCode == "USD" {
119125
priceValueInUSD = priceValueInNativeCurrency
120126
} else if
121-
let currencyCode = transaction.currencyCode,
122-
let oneUSDExchangeRate = self.currencyCodeToOneUSDExchangeRate[currencyCode]
127+
let currencyCode = self.currencyCode,
128+
let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode]
123129
{
124130
priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate
125131
} else {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import StoreKit
2+
3+
/// Responsible for tracking free trial subscriptions and detecting when they convert to paid subscriptions or are canceled.
4+
///
5+
/// This class manages the lifecycle of free trials by:
6+
/// - Storing information about the last active free trial in UserDefaults
7+
/// - Monitoring StoreKit transactions for trial conversions and cancellations
8+
/// - Sending telemetry signals when a trial converts to a paid subscription
9+
///
10+
/// The API call needed to make outside it is this:
11+
/// ```
12+
/// // When a free trial is started
13+
/// TrialConversionTracker.shared.freeTrialStarted(transaction: transaction)
14+
/// ```
15+
///
16+
/// This type automatically starts monitoring transactions during a free trial phase and stops doing so when no longer needed.
17+
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
18+
final class TrialConversionTracker: @unchecked Sendable {
19+
private struct StoredTrial: Codable {
20+
let productId: String
21+
let originalTransactionId: UInt64
22+
}
23+
24+
static let shared = TrialConversionTracker()
25+
26+
private static let lastTrialKey = "lastTrial"
27+
28+
private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.trialtracker.persistence")
29+
private var transactionUpdateTask: Task<Void, Error>?
30+
31+
private var currentTrial: StoredTrial? {
32+
get {
33+
if
34+
let trialData = TelemetryDeck.customDefaults?.data(forKey: Self.lastTrialKey),
35+
let trial = try? JSONDecoder().decode(StoredTrial.self, from: trialData)
36+
{
37+
return trial
38+
}
39+
40+
return nil
41+
}
42+
43+
set {
44+
self.persistenceQueue.async {
45+
if let trial = newValue, let encodedData = try? JSONEncoder().encode(trial) {
46+
TelemetryDeck.customDefaults?.set(encodedData, forKey: Self.lastTrialKey)
47+
} else {
48+
TelemetryDeck.customDefaults?.removeObject(forKey: Self.lastTrialKey)
49+
}
50+
}
51+
}
52+
}
53+
54+
private init() {
55+
// Start observing transactions if there's an active trial
56+
if currentTrial != nil {
57+
self.startObservingTransactions()
58+
}
59+
}
60+
61+
/// Call this function only after having validated that the passed transaction is a free trial.
62+
func freeTrialStarted(transaction: Transaction) {
63+
let trial = StoredTrial(productId: transaction.productID, originalTransactionId: transaction.originalID)
64+
self.currentTrial = trial
65+
self.startObservingTransactions()
66+
}
67+
68+
private func clearCurrentTrial() {
69+
self.currentTrial = nil
70+
self.stopObservingTransactions()
71+
}
72+
73+
private func startObservingTransactions() {
74+
// Cancel any existing observation
75+
self.stopObservingTransactions()
76+
77+
// Start new observation
78+
self.transactionUpdateTask = Task {
79+
for await verificationResult in Transaction.updates {
80+
// Check if transaction is verified
81+
guard case .verified(let transaction) = verificationResult else { continue }
82+
83+
// Check if this transaction matches our trial product
84+
if
85+
let currentTrial = self.currentTrial,
86+
transaction.productID == currentTrial.productId,
87+
transaction.originalID == currentTrial.originalTransactionId
88+
{
89+
90+
// Case 1: Trial converted to paid subscription
91+
if !transaction.isUpgraded && !transaction.isFreeTrial {
92+
TelemetryDeck.internalSignal(
93+
"TelemetryDeck.Purchase.convertedFromTrial",
94+
parameters: transaction.purchaseParameters(),
95+
floatValue: transaction.priceInUSD()
96+
)
97+
98+
self.clearCurrentTrial()
99+
}
100+
101+
// Case 2: Trial was canceled or expired, let's clean up & stop observing
102+
else if transaction.revocationDate != nil || transaction.expirationDate?.isInThePast == true {
103+
self.clearCurrentTrial()
104+
}
105+
}
106+
}
107+
}
108+
}
109+
110+
private func stopObservingTransactions() {
111+
self.transactionUpdateTask?.cancel()
112+
self.transactionUpdateTask = nil
113+
}
114+
}
115+
116+
// Convenience extension to check trial status
117+
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
118+
extension Transaction {
119+
var isFreeTrial: Bool {
120+
if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) {
121+
return self.offer?.type == .introductory && self.offer?.paymentMode == .freeTrial
122+
} else {
123+
return self.offerType == .introductory && self.offerPaymentModeStringRepresentation == "FREE_TRIAL"
124+
}
125+
}
126+
}
127+
128+
extension Date {
129+
var isInThePast: Bool {
130+
self.timeIntervalSinceNow < 0
131+
}
132+
}

0 commit comments

Comments
 (0)