diff --git a/README.md b/README.md index 7acc872..89e7d27 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,24 @@ With each Signal, the client sends a hash of your user ID as well as a _session On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background, or if it is launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like more fine-grained session support, write a new random session identifier into the `TelemetryDeck.Config`'s `sessionID` property each time a new session begins. +## Purchase Tracking + +The SDK offers a shorthand for sending events related to a purchase: + +```swift +await TelemetryDeck.purchaseCompleted( + productID: "pro.yearly", + type: .subscription, + price: 49.99, + currencyCode: "USD", + countryCode: "US" +) +``` + +The price is converted to USD for revenue analytics. Use `freeTrialStarted(...)` when a free trial begins and `convertedFromTrial(...)` when a trial converts to a paid purchase. The SDK does not keep track of purchases you have already reported — repeatedly calling these methods results in multiple events. + +The `purchaseCompleted(transaction:parameters:customUserID:)` overload taking a `StoreKit.Transaction` is deprecated. Automatic free trial and trial conversion detection is only available through that deprecated overload; the manual methods above require you to call `freeTrialStarted(...)` and `convertedFromTrial(...)` yourself. + ## Custom Salt By default, user identifiers are hashed by the TelemetryDeck SDK, and then sent to the Ingestion API, where we'll add a salt to the received identifier and hash it again. diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift index 1bae9b1..97fbe23 100644 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift +++ b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift @@ -1,6 +1,306 @@ +import Foundation + +extension TelemetryDeck { + /// The type of a purchase. + public enum PurchaseType: String, Sendable { + case subscription + case oneTimePurchase = "one-time-purchase" + } + + /// Sends a telemetry signal indicating that a purchase has been completed. + /// + /// - Parameters: + /// - productID: The identifier of the purchased product. + /// - type: Whether the purchase is a subscription or a one-time purchase. + /// - price: The price paid, in the currency identified by `currencyCode`. + /// - currencyCode: The ISO 4217 currency code of `price`. + /// - countryCode: The ISO 3166-1 country code of the storefront the purchase was made on. Default is `nil`. + /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. + /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. + /// + /// This function converts `price` to USD for revenue analytics. The conversion happens with hard-coded exchange rates that might be out of date. + /// The SDK does not keep track of purchases you have already reported — repeatedly calling this method will result in multiple events. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + public static func purchaseCompleted( + productID: String, + type: PurchaseType, + price: Decimal, + currencyCode: String, + countryCode: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) async { + assert(!productID.isEmpty, "productID must not be empty") + assert(!currencyCode.isEmpty, "currencyCode must not be empty") + + self.internalSignal( + "TelemetryDeck.Purchase.completed", + parameters: purchaseParameters(productID: productID, type: type, currencyCode: currencyCode, countryCode: countryCode) + .merging(parameters) { $1 }, + floatValue: priceInUSD(price, currencyCode: currencyCode), + customUserID: customUserID + ) + } + + /// Sends a telemetry signal indicating that a user converted from a free trial to a paid purchase. + /// + /// - Parameters: + /// - productID: The identifier of the purchased product. + /// - type: Whether the purchase is a subscription or a one-time purchase. + /// - price: The price paid, in the currency identified by `currencyCode`. + /// - currencyCode: The ISO 4217 currency code of `price`. + /// - countryCode: The ISO 3166-1 country code of the storefront the purchase was made on. Default is `nil`. + /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. + /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. + /// + /// This function converts `price` to USD for revenue analytics. The conversion happens with hard-coded exchange rates that might be out of date. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + public static func convertedFromTrial( + productID: String, + type: PurchaseType, + price: Decimal, + currencyCode: String, + countryCode: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) async { + assert(!productID.isEmpty, "productID must not be empty") + assert(!currencyCode.isEmpty, "currencyCode must not be empty") + + self.internalSignal( + "TelemetryDeck.Purchase.convertedFromTrial", + parameters: purchaseParameters(productID: productID, type: type, currencyCode: currencyCode, countryCode: countryCode) + .merging(parameters) { $1 }, + floatValue: priceInUSD(price, currencyCode: currencyCode), + customUserID: customUserID + ) + } + + /// Sends a telemetry signal indicating that a free trial has started. + /// + /// - Parameters: + /// - productID: The identifier of the product the trial is for. + /// - type: Whether the trial is for a subscription or a one-time purchase. + /// - currencyCode: The ISO 4217 currency code the product is priced in. + /// - countryCode: The ISO 3166-1 country code of the storefront the trial was started on. Default is `nil`. + /// - parameters: Additional parameters to include with the signal. Default is an empty dictionary. + /// - customUserID: An optional custom user identifier. If provided, it overrides the default user identifier from the configuration. Default is `nil`. + /// + /// No `floatValue` is recorded because a free trial has no charge. Call ``convertedFromTrial(productID:type:price:currencyCode:countryCode:parameters:customUserID:)`` + /// when the trial converts to a paid purchase. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + public static func freeTrialStarted( + productID: String, + type: PurchaseType, + currencyCode: String, + countryCode: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) async { + assert(!productID.isEmpty, "productID must not be empty") + assert(!currencyCode.isEmpty, "currencyCode must not be empty") + + self.internalSignal( + "TelemetryDeck.Purchase.freeTrialStarted", + parameters: purchaseParameters(productID: productID, type: type, currencyCode: currencyCode, countryCode: countryCode) + .merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + static func purchaseParameters(productID: String, type: PurchaseType, currencyCode: String, countryCode: String?) -> [String: String] { + var parameters: [String: String] = [ + "TelemetryDeck.Purchase.type": type.rawValue, + "TelemetryDeck.Purchase.productID": productID, + "TelemetryDeck.Purchase.currencyCode": currencyCode, + ] + + if let countryCode { + parameters["TelemetryDeck.Purchase.countryCode"] = countryCode + } + + return parameters + } + + static func priceInUSD(_ price: Decimal, currencyCode: String) -> Double { + let priceValueInNativeCurrency = NSDecimalNumber(decimal: price).doubleValue + + if currencyCode == "USD" { + return priceValueInNativeCurrency + } else if let oneUSDExchangeRate = currencyCodeToOneUSDExchangeRate[currencyCode] { + return priceValueInNativeCurrency / oneUSDExchangeRate + } else { + return 0 + } + } + + private static let currencyCodeToOneUSDExchangeRate: [String: Double] = [ + "AED": 3.6725, + "AFN": 73.1439, + "ALL": 94.4244, + "AMD": 396.6171, + "ANG": 1.7900, + "AOA": 915.1721, + "ARS": 1058.5000, + "AUD": 1.5742, + "AWG": 1.7900, + "AZN": 1.7002, + "BAM": 1.8645, + "BBD": 2.0000, + "BDT": 121.5449, + "BGN": 1.8646, + "BHD": 0.3760, + "BIF": 2964.2266, + "BMD": 1.0000, + "BND": 1.3398, + "BOB": 6.9305, + "BRL": 5.7132, + "BSD": 1.0000, + "BTN": 86.7994, + "BWP": 13.8105, + "BYN": 3.2699, + "BZD": 2.0000, + "CAD": 1.4182, + "CDF": 2856.7620, + "CHF": 0.8997, + "CLP": 946.3948, + "CNY": 7.2626, + "COP": 4127.8455, + "CRC": 507.0750, + "CUP": 24.0000, + "CVE": 105.1179, + "CZK": 23.8700, + "DJF": 177.7210, + "DKK": 7.1119, + "DOP": 62.0869, + "DZD": 135.3706, + "EGP": 50.6290, + "ERN": 15.0000, + "ETB": 126.2459, + "EUR": 0.9533, + "FJD": 2.2940, + "FKP": 0.7948, + "FOK": 7.1120, + "GBP": 0.7948, + "GEL": 2.8302, + "GGP": 0.7948, + "GHS": 15.4508, + "GIP": 0.7948, + "GMD": 72.6046, + "GNF": 8589.0144, + "GTQ": 7.7216, + "GYD": 209.2593, + "HKD": 7.7837, + "HNL": 25.5206, + "HRK": 7.1828, + "HTG": 130.8347, + "HUF": 383.5426, + "IDR": 16225.1575, + "ILS": 3.5481, + "IMP": 0.7948, + "INR": 86.7955, + "IQD": 1307.9508, + "IRR": 41993.2160, + "ISK": 140.4283, + "JEP": 0.7948, + "JMD": 157.9457, + "JOD": 0.7090, + "JPY": 152.3479, + "KES": 129.2574, + "KGS": 87.4567, + "KHR": 4008.1629, + "KID": 1.5744, + "KMF": 469.0028, + "KRW": 1440.3458, + "KWD": 0.3085, + "KYD": 0.8333, + "KZT": 497.5012, + "LAK": 21867.2622, + "LBP": 89500.0000, + "LKR": 295.5196, + "LRD": 199.3352, + "LSL": 18.3599, + "LYD": 4.9073, + "MAD": 9.9608, + "MDL": 18.8154, + "MGA": 4734.8216, + "MKD": 58.8122, + "MMK": 2099.5486, + "MNT": 3439.8970, + "MOP": 8.0173, + "MRU": 39.9597, + "MUR": 46.4371, + "MVR": 15.4548, + "MWK": 1736.3946, + "MXN": 20.3269, + "MYR": 4.4350, + "MZN": 63.6976, + "NAD": 18.3599, + "NGN": 1509.8070, + "NIO": 36.7984, + "NOK": 11.1191, + "NPR": 138.8791, + "NZD": 1.7453, + "OMR": 0.3845, + "PAB": 1.0000, + "PEN": 3.7091, + "PGK": 4.0165, + "PHP": 57.7773, + "PKR": 279.0304, + "PLN": 3.9665, + "PYG": 7905.2559, + "QAR": 3.6400, + "RON": 4.7473, + "RSD": 111.7081, + "RUB": 91.0874, + "RWF": 1405.5288, + "SAR": 3.7500, + "SBD": 8.6689, + "SCR": 14.4355, + "SDG": 459.0793, + "SEK": 10.6997, + "SGD": 1.3398, + "SHP": 0.7948, + "SLE": 22.8772, + "SLL": 22877.1788, + "SOS": 571.5471, + "SRD": 35.4328, + "SSP": 4391.5735, + "STN": 23.3563, + "SYP": 12933.0491, + "SZL": 18.3599, + "THB": 33.6413, + "TJS": 10.9222, + "TMT": 3.5008, + "TND": 3.1727, + "TOP": 2.3859, + "TRY": 36.2290, + "TTD": 6.7863, + "TVD": 1.5744, + "TWD": 32.6576, + "TZS": 2592.2504, + "UAH": 41.5989, + "UGX": 3674.9872, + "UYU": 43.2704, + "UZS": 12992.6998, + "VES": 62.0708, + "VND": 25400.2138, + "VUV": 123.0591, + "WST": 2.8244, + "XAF": 625.3371, + "XCD": 2.7000, + "XDR": 0.7614, + "XOF": 625.3371, + "XPF": 113.7616, + "YER": 247.9730, + "ZAR": 18.3601, + "ZMW": 28.1645, + "ZWL": 26.4365, + ] +} + #if canImport(StoreKit) && compiler(>=5.9.2) import StoreKit - import Foundation @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *) extension TelemetryDeck { @@ -14,6 +314,12 @@ /// This function captures details about the completed purchase, including the type of purchase (subscription or one-time), /// the country code of the storefront, and the currency code. It also converts the price to USD if necessary and sends /// this information as a telemetry signal. The conversion happens with hard-coded values that might be out of date. + @available( + *, + deprecated, + message: + "Use 'purchaseCompleted(productID:type:price:currencyCode:countryCode:parameters:customUserID:)' instead. Note: the replacement methods do not detect free trials or trial conversions automatically – call 'freeTrialStarted(...)' when a trial begins and 'convertedFromTrial(...)' when it converts." + ) public static func purchaseCompleted( transaction: StoreKit.Transaction, parameters: [String: String] = [:], @@ -104,196 +410,15 @@ } func priceInUSD() -> Double { - let priceValueInNativeCurrency = NSDecimalNumber(decimal: self.price ?? Decimal()).doubleValue - let priceValueInUSD: Double + let priceValueInNativeCurrency = self.price ?? Decimal() if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { - if self.currency?.identifier == "USD" { - priceValueInUSD = priceValueInNativeCurrency - } else if let currencyCode = self.currency?.identifier, - let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode] - { - priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate - } else { - priceValueInUSD = 0 - } + let currencyCode = self.currency?.identifier ?? "" + return TelemetryDeck.priceInUSD(priceValueInNativeCurrency, currencyCode: currencyCode) } else { - if self.currencyCode == "USD" { - priceValueInUSD = priceValueInNativeCurrency - } else if let currencyCode = self.currencyCode, - let oneUSDExchangeRate = Self.currencyCodeToOneUSDExchangeRate[currencyCode] - { - priceValueInUSD = priceValueInNativeCurrency / oneUSDExchangeRate - } else { - priceValueInUSD = 0 - } + let currencyCode = self.currencyCode ?? "" + return TelemetryDeck.priceInUSD(priceValueInNativeCurrency, currencyCode: currencyCode) } - - return priceValueInUSD } - - private static let currencyCodeToOneUSDExchangeRate: [String: Double] = [ - "AED": 3.6725, - "AFN": 73.1439, - "ALL": 94.4244, - "AMD": 396.6171, - "ANG": 1.7900, - "AOA": 915.1721, - "ARS": 1058.5000, - "AUD": 1.5742, - "AWG": 1.7900, - "AZN": 1.7002, - "BAM": 1.8645, - "BBD": 2.0000, - "BDT": 121.5449, - "BGN": 1.8646, - "BHD": 0.3760, - "BIF": 2964.2266, - "BMD": 1.0000, - "BND": 1.3398, - "BOB": 6.9305, - "BRL": 5.7132, - "BSD": 1.0000, - "BTN": 86.7994, - "BWP": 13.8105, - "BYN": 3.2699, - "BZD": 2.0000, - "CAD": 1.4182, - "CDF": 2856.7620, - "CHF": 0.8997, - "CLP": 946.3948, - "CNY": 7.2626, - "COP": 4127.8455, - "CRC": 507.0750, - "CUP": 24.0000, - "CVE": 105.1179, - "CZK": 23.8700, - "DJF": 177.7210, - "DKK": 7.1119, - "DOP": 62.0869, - "DZD": 135.3706, - "EGP": 50.6290, - "ERN": 15.0000, - "ETB": 126.2459, - "EUR": 0.9533, - "FJD": 2.2940, - "FKP": 0.7948, - "FOK": 7.1120, - "GBP": 0.7948, - "GEL": 2.8302, - "GGP": 0.7948, - "GHS": 15.4508, - "GIP": 0.7948, - "GMD": 72.6046, - "GNF": 8589.0144, - "GTQ": 7.7216, - "GYD": 209.2593, - "HKD": 7.7837, - "HNL": 25.5206, - "HRK": 7.1828, - "HTG": 130.8347, - "HUF": 383.5426, - "IDR": 16225.1575, - "ILS": 3.5481, - "IMP": 0.7948, - "INR": 86.7955, - "IQD": 1307.9508, - "IRR": 41993.2160, - "ISK": 140.4283, - "JEP": 0.7948, - "JMD": 157.9457, - "JOD": 0.7090, - "JPY": 152.3479, - "KES": 129.2574, - "KGS": 87.4567, - "KHR": 4008.1629, - "KID": 1.5744, - "KMF": 469.0028, - "KRW": 1440.3458, - "KWD": 0.3085, - "KYD": 0.8333, - "KZT": 497.5012, - "LAK": 21867.2622, - "LBP": 89500.0000, - "LKR": 295.5196, - "LRD": 199.3352, - "LSL": 18.3599, - "LYD": 4.9073, - "MAD": 9.9608, - "MDL": 18.8154, - "MGA": 4734.8216, - "MKD": 58.8122, - "MMK": 2099.5486, - "MNT": 3439.8970, - "MOP": 8.0173, - "MRU": 39.9597, - "MUR": 46.4371, - "MVR": 15.4548, - "MWK": 1736.3946, - "MXN": 20.3269, - "MYR": 4.4350, - "MZN": 63.6976, - "NAD": 18.3599, - "NGN": 1509.8070, - "NIO": 36.7984, - "NOK": 11.1191, - "NPR": 138.8791, - "NZD": 1.7453, - "OMR": 0.3845, - "PAB": 1.0000, - "PEN": 3.7091, - "PGK": 4.0165, - "PHP": 57.7773, - "PKR": 279.0304, - "PLN": 3.9665, - "PYG": 7905.2559, - "QAR": 3.6400, - "RON": 4.7473, - "RSD": 111.7081, - "RUB": 91.0874, - "RWF": 1405.5288, - "SAR": 3.7500, - "SBD": 8.6689, - "SCR": 14.4355, - "SDG": 459.0793, - "SEK": 10.6997, - "SGD": 1.3398, - "SHP": 0.7948, - "SLE": 22.8772, - "SLL": 22877.1788, - "SOS": 571.5471, - "SRD": 35.4328, - "SSP": 4391.5735, - "STN": 23.3563, - "SYP": 12933.0491, - "SZL": 18.3599, - "THB": 33.6413, - "TJS": 10.9222, - "TMT": 3.5008, - "TND": 3.1727, - "TOP": 2.3859, - "TRY": 36.2290, - "TTD": 6.7863, - "TVD": 1.5744, - "TWD": 32.6576, - "TZS": 2592.2504, - "UAH": 41.5989, - "UGX": 3674.9872, - "UYU": 43.2704, - "UZS": 12992.6998, - "VES": 62.0708, - "VND": 25400.2138, - "VUV": 123.0591, - "WST": 2.8244, - "XAF": 625.3371, - "XCD": 2.7000, - "XDR": 0.7614, - "XOF": 625.3371, - "XPF": 113.7616, - "YER": 247.9730, - "ZAR": 18.3601, - "ZMW": 28.1645, - "ZWL": 26.4365, - ] } #endif diff --git a/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift b/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift index 83adb02..8bd6d46 100644 --- a/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift +++ b/Sources/TelemetryDeck/Presets/TrialConversionTracker.swift @@ -3,111 +3,214 @@ import StoreKit /// Responsible for tracking free trial subscriptions and detecting when they convert to paid subscriptions or are canceled. /// /// This class manages the lifecycle of free trials by: -/// - Storing information about the last active free trial in UserDefaults +/// - Storing information about active free trials in UserDefaults /// - Monitoring StoreKit transactions for trial conversions and cancellations /// - Sending telemetry signals when a trial converts to a paid subscription /// -/// The API call needed to make outside it is this: +/// Outside of this type, two calls are required to get correct trial-to-paid reporting: /// ``` /// // When a free trial is started /// TrialConversionTracker.shared.freeTrialStarted(transaction: transaction) +/// +/// // Once, at app launch (already done by TelemetryDeck.initialize) +/// TrialConversionTracker.shared.start() /// ``` /// -/// This type automatically starts monitoring transactions during a free trial phase and stops doing so when no longer needed. +/// `start()` reconciles any trials that were persisted from a previous launch against their current StoreKit state, which is what lets a trial that converted to paid (or lapsed) while the app was not running still get reported. +/// Once a trial is active, this type automatically starts monitoring live transaction updates and stops doing so when no persisted trial remains. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) final class TrialConversionTracker: @unchecked Sendable { - private struct StoredTrial: Codable { + struct StoredTrial: Codable { let productID: String let originalTransactionID: UInt64 } + enum TrialOutcome: Equatable { case convertedToPaid, cancelledOrExpired, stillOnTrial } + static let shared = TrialConversionTracker() - private static let lastTrialKey = "lastTrial" + static let activeTrialsKey = "activeTrials" + static let legacyTrialKey = "lastTrial" private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.trialtracker.persistence") private var transactionUpdateTask: Task? - private var currentTrial: StoredTrial? { - get { - if let trialData = TelemetryDeck.customDefaults?.data(forKey: Self.lastTrialKey), - let trial = try? JSONDecoder().decode(StoredTrial.self, from: trialData) - { - return trial - } + /// Supplies the `UserDefaults` suite this tracker persists trials to. + /// + /// Defaults to the app's TelemetryDeck suite; injectable so tests can operate on an isolated suite + /// without going through `TelemetryDeck.initialize`. + private let userDefaults: () -> UserDefaults? - return nil + init(userDefaults: @escaping () -> UserDefaults? = { TelemetryDeck.customDefaults }) { + self.userDefaults = userDefaults + migrateLegacyTrialIfNeeded() + } + + /// Reconciles trials persisted from a previous launch against their current StoreKit state. + /// + /// A trial that converted to paid, or lapsed, while the app was not running is only detected here, so this + /// must be called once per launch before any such conversion can be reported. `TelemetryDeck.initialize` + /// already calls this; nothing else needs to invoke it. + func start() { + let trials = currentTrials() + guard !trials.isEmpty else { return } + reconcilePersistedTrials(trials) + } + + /// Call this function only after having validated that the passed transaction is a free trial. + func freeTrialStarted(transaction: Transaction) { + let trial = StoredTrial(productID: transaction.productID, originalTransactionID: transaction.originalID) + persistenceQueue.sync { + var trials = loadTrials() + trials[transaction.productID] = trial + saveTrials(trials) } + startObservingTransactions() + } - set { - self.persistenceQueue.async { - if let trial = newValue, let encodedData = try? JSONEncoder().encode(trial) { - TelemetryDeck.customDefaults?.set(encodedData, forKey: Self.lastTrialKey) - } else { - TelemetryDeck.customDefaults?.removeObject(forKey: Self.lastTrialKey) - } - } + private static func classify(_ transaction: Transaction, against trial: StoredTrial) -> TrialOutcome? { + guard transaction.productID == trial.productID, + transaction.originalID == trial.originalTransactionID + else { return nil } + return classify( + isRevoked: transaction.revocationDate != nil, + isUpgraded: transaction.isUpgraded, + isFreeTrial: transaction.isFreeTrial, + isExpired: transaction.expirationDate?.isInThePast == true + ) + } + + /// Determines what a trial's matching transaction means for that trial, from plain transaction attributes. + /// + /// Revocation and upgrades are checked before the free-trial status, because both mean the transaction no + /// longer represents an outcome we should report: a revoked/refunded purchase must never be counted as a + /// conversion, and a transaction superseded by an upgrade no longer reflects this product's own lifecycle. + /// Only once those are ruled out do we ask whether the transaction is still a free trial. If it is not, + /// the trial converted to paid, even if that paid period has since expired — expiration only cancels a + /// trial that never converted. + static func classify(isRevoked: Bool, isUpgraded: Bool, isFreeTrial: Bool, isExpired: Bool) -> TrialOutcome { + if isRevoked || isUpgraded { + return .cancelledOrExpired + } + if isFreeTrial { + return isExpired ? .cancelledOrExpired : .stillOnTrial } + return .convertedToPaid } - private init() { - // Start observing transactions if there's an active trial - if currentTrial != nil { - self.startObservingTransactions() + /// Claims and reports the outcome of a trial's matching `transaction`, if any. + /// + /// - Returns: `true` if the trial is still active and nothing was claimed, `false` once it has been resolved. + @discardableResult + private func handleOutcome(_ outcome: TrialOutcome?, for transaction: Transaction) -> Bool { + switch outcome { + case .convertedToPaid: + if claimTrial(productID: transaction.productID) != nil { + reportConversion(transaction) + } + return false + case .cancelledOrExpired: + claimTrial(productID: transaction.productID) + return false + case .stillOnTrial, nil: + return true } } - /// Call this function only after having validated that the passed transaction is a free trial. - func freeTrialStarted(transaction: Transaction) { - let trial = StoredTrial(productID: transaction.productID, originalTransactionID: transaction.originalID) - self.currentTrial = trial - self.startObservingTransactions() + private func reconcilePersistedTrials(_ trials: [String: StoredTrial]) { + Task { + var anyStillActive = false + for (productID, trial) in trials { + guard case .verified(let transaction)? = await Transaction.latest(for: productID) else { + anyStillActive = true + continue + } + if self.handleOutcome(Self.classify(transaction, against: trial), for: transaction) { + anyStillActive = true + } + } + if anyStillActive { + self.startObservingTransactions() + } + } } - private func clearCurrentTrial() { - self.currentTrial = nil - self.stopObservingTransactions() + private func reportConversion(_ transaction: Transaction) { + TelemetryDeck.internalSignal( + "TelemetryDeck.Purchase.convertedFromTrial", + parameters: transaction.purchaseParameters(), + floatValue: transaction.priceInUSD() + ) } private func startObservingTransactions() { - // Cancel any existing observation - self.stopObservingTransactions() - - // Start new observation - self.transactionUpdateTask = Task { - for await verificationResult in Transaction.updates { - // Check if transaction is verified - guard case .verified(let transaction) = verificationResult else { continue } - - // Check if this transaction matches our trial product - if let currentTrial = self.currentTrial, - transaction.productID == currentTrial.productID, - transaction.originalID == currentTrial.originalTransactionID - { - if transaction.revocationDate != nil - || transaction.expirationDate?.isInThePast == true - || transaction.isUpgraded - { - // Trial was canceled, has expired, or was upgraded – let's clean up & stop observing - self.clearCurrentTrial() - } else if !transaction.isFreeTrial { - // Trial converted to paid subscription - TelemetryDeck.internalSignal( - "TelemetryDeck.Purchase.convertedFromTrial", - parameters: transaction.purchaseParameters(), - floatValue: transaction.priceInUSD() - ) - - self.clearCurrentTrial() - } + persistenceQueue.sync { + guard transactionUpdateTask == nil else { return } + transactionUpdateTask = Task { + for await verificationResult in Transaction.updates { + guard case .verified(let transaction) = verificationResult else { continue } + let trials = self.currentTrials() + guard let trial = trials[transaction.productID] else { continue } + self.handleOutcome(Self.classify(transaction, against: trial), for: transaction) } } } } private func stopObservingTransactions() { - self.transactionUpdateTask?.cancel() - self.transactionUpdateTask = nil + persistenceQueue.sync { + transactionUpdateTask?.cancel() + transactionUpdateTask = nil + } + } + + @discardableResult + func claimTrial(productID: String) -> StoredTrial? { + let claim: (trial: StoredTrial?, remaining: [String: StoredTrial]) = persistenceQueue.sync { + var trials = loadTrials() + let claimed = trials.removeValue(forKey: productID) + if claimed != nil { + saveTrials(trials) + } + return (claimed, trials) + } + if claim.trial != nil, claim.remaining.isEmpty { + stopObservingTransactions() + } + return claim.trial + } + + func migrateLegacyTrialIfNeeded() { + persistenceQueue.sync { + guard let data = userDefaults()?.data(forKey: Self.legacyTrialKey), + let trial = try? JSONDecoder().decode(StoredTrial.self, from: data) + else { return } + var trials = loadTrials() + if trials[trial.productID] == nil { + trials[trial.productID] = trial + saveTrials(trials) + } + userDefaults()?.removeObject(forKey: Self.legacyTrialKey) + } + } + + private func currentTrials() -> [String: StoredTrial] { + persistenceQueue.sync { loadTrials() } + } + + func loadTrials() -> [String: StoredTrial] { + guard let data = userDefaults()?.data(forKey: Self.activeTrialsKey), + let trials = try? JSONDecoder().decode([String: StoredTrial].self, from: data) + else { return [:] } + return trials + } + + func saveTrials(_ trials: [String: StoredTrial]) { + if trials.isEmpty { + userDefaults()?.removeObject(forKey: Self.activeTrialsKey) + } else if let data = try? JSONEncoder().encode(trials) { + userDefaults()?.set(data, forKey: Self.activeTrialsKey) + } } } diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index c546ae8..126130e 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -22,6 +22,11 @@ public enum TelemetryDeck { /// For example, you might want to call this in your `init` method of your app's `@main` entry point. public static func initialize(config: Config) { TelemetryManager.initializedTelemetryManager = TelemetryManager(configuration: config) + #if canImport(StoreKit) && compiler(>=5.9.2) + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + TrialConversionTracker.shared.start() + } + #endif } /// Sends a telemetry signal with optional parameters to TelemetryDeck. diff --git a/Tests/TelemetryDeckTests/PurchasesTests.swift b/Tests/TelemetryDeckTests/PurchasesTests.swift new file mode 100644 index 0000000..cc0da79 --- /dev/null +++ b/Tests/TelemetryDeckTests/PurchasesTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +struct PurchasesTests { + // MARK: - priceInUSD + + @Test + func priceInUSDPassthroughForUSD() { + let result = TelemetryDeck.priceInUSD(Decimal(9.99), currencyCode: "USD") + #expect(abs(result - 9.99) < 0.001) + } + + @Test + func priceInUSDConvertsDividesByTableRate() { + let eurRate = 0.9533 + let price = Decimal(9.99) + let expected = NSDecimalNumber(decimal: price).doubleValue / eurRate + let result = TelemetryDeck.priceInUSD(price, currencyCode: "EUR") + #expect(abs(result - expected) < 0.001) + } + + @Test + func priceInUSDReturnsZeroForUnknownCode() { + let result = TelemetryDeck.priceInUSD(Decimal(9.99), currencyCode: "ZZZ") + #expect(result == 0) + } + + // MARK: - purchaseParameters + + @Test + func purchaseParametersSubscriptionType() { + let params = TelemetryDeck.purchaseParameters( + productID: "com.example.monthly", + type: .subscription, + currencyCode: "USD", + countryCode: nil + ) + #expect(params["TelemetryDeck.Purchase.type"] == "subscription") + } + + @Test + func purchaseParametersOneTimePurchaseType() { + let params = TelemetryDeck.purchaseParameters( + productID: "com.example.pro", + type: .oneTimePurchase, + currencyCode: "USD", + countryCode: nil + ) + #expect(params["TelemetryDeck.Purchase.type"] == "one-time-purchase") + } + + @Test + func purchaseParametersContainsProductIDAndCurrencyCode() { + let params = TelemetryDeck.purchaseParameters( + productID: "com.example.pro", + type: .oneTimePurchase, + currencyCode: "EUR", + countryCode: nil + ) + #expect(params["TelemetryDeck.Purchase.productID"] == "com.example.pro") + #expect(params["TelemetryDeck.Purchase.currencyCode"] == "EUR") + } + + @Test + func purchaseParametersOmitsCountryCodeWhenNil() { + let params = TelemetryDeck.purchaseParameters( + productID: "com.example.pro", + type: .subscription, + currencyCode: "USD", + countryCode: nil + ) + #expect(params["TelemetryDeck.Purchase.countryCode"] == nil) + } + + @Test + func purchaseParametersIncludesCountryCodeWhenProvided() { + let params = TelemetryDeck.purchaseParameters( + productID: "com.example.pro", + type: .subscription, + currencyCode: "USD", + countryCode: "US" + ) + #expect(params["TelemetryDeck.Purchase.countryCode"] == "US") + } +} diff --git a/Tests/TelemetryDeckTests/TrialConversionTrackerTests.swift b/Tests/TelemetryDeckTests/TrialConversionTrackerTests.swift new file mode 100644 index 0000000..3a386d4 --- /dev/null +++ b/Tests/TelemetryDeckTests/TrialConversionTrackerTests.swift @@ -0,0 +1,231 @@ +import Foundation +import Testing + +@testable import TelemetryDeck + +@Suite(.serialized) +struct TrialConversionTrackerTests { + /// Creates a `TrialConversionTracker` backed by an in-memory `UserDefaults` double, so the test neither + /// touches the global `TelemetryManager` static nor writes any preferences file to disk. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + private func isolatedTracker() -> (tracker: TrialConversionTracker, defaults: UserDefaults) { + let defaults = InMemoryUserDefaults() + let tracker = TrialConversionTracker(userDefaults: { defaults }) + return (tracker, defaults) + } + + // MARK: - Persistence round-trip + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func saveTrials_loadTrials_roundTripsStoredTrial() { + let (tracker, _) = isolatedTracker() + + let trial = TrialConversionTracker.StoredTrial(productID: "com.app.pro.monthly", originalTransactionID: 123) + tracker.saveTrials(["com.app.pro.monthly": trial]) + + let loaded = tracker.loadTrials() + #expect(loaded["com.app.pro.monthly"]?.productID == "com.app.pro.monthly") + #expect(loaded["com.app.pro.monthly"]?.originalTransactionID == 123) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func saveTrials_withEmptyDictionary_removesStoredData() { + let (tracker, _) = isolatedTracker() + + let trial = TrialConversionTracker.StoredTrial(productID: "com.app.pro.monthly", originalTransactionID: 123) + tracker.saveTrials(["com.app.pro.monthly": trial]) + #expect(!tracker.loadTrials().isEmpty) + + tracker.saveTrials([:]) + #expect(tracker.loadTrials().isEmpty) + } + + // MARK: - Legacy migration + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func migrateLegacyTrialIfNeeded_movesLegacyTrialIntoDictionaryAndClearsLegacyKey() throws { + let (tracker, defaults) = isolatedTracker() + + let legacyTrial = TrialConversionTracker.StoredTrial(productID: "com.app.legacy", originalTransactionID: 42) + defaults.set(try JSONEncoder().encode(legacyTrial), forKey: TrialConversionTracker.legacyTrialKey) + + tracker.migrateLegacyTrialIfNeeded() + + #expect(tracker.loadTrials()["com.app.legacy"]?.originalTransactionID == 42) + #expect(defaults.data(forKey: TrialConversionTracker.legacyTrialKey) == nil) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func migrateLegacyTrialIfNeeded_calledTwice_doesNotDuplicate() throws { + let (tracker, defaults) = isolatedTracker() + + let legacyTrial = TrialConversionTracker.StoredTrial(productID: "com.app.legacy", originalTransactionID: 42) + defaults.set(try JSONEncoder().encode(legacyTrial), forKey: TrialConversionTracker.legacyTrialKey) + + tracker.migrateLegacyTrialIfNeeded() + tracker.migrateLegacyTrialIfNeeded() + + #expect(tracker.loadTrials().count == 1) + } + + /// A migrated trial that is later claimed (i.e. reported as converted or cancelled) must stay gone: if a + /// second migration pass resurrected it from a lingering legacy key, the same conversion would be reported + /// twice. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func migrateLegacyTrialIfNeeded_afterTrialWasClaimed_doesNotResurrectIt() throws { + let (tracker, defaults) = isolatedTracker() + + let legacyTrial = TrialConversionTracker.StoredTrial(productID: "com.app.legacy", originalTransactionID: 42) + defaults.set(try JSONEncoder().encode(legacyTrial), forKey: TrialConversionTracker.legacyTrialKey) + + tracker.migrateLegacyTrialIfNeeded() + #expect(tracker.claimTrial(productID: "com.app.legacy") != nil) + + tracker.migrateLegacyTrialIfNeeded() + + #expect(tracker.loadTrials()["com.app.legacy"] == nil) + } + + // MARK: - Claiming trials + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func claimTrial_forExistingProduct_removesAndReturnsIt() { + let (tracker, _) = isolatedTracker() + + let trial = TrialConversionTracker.StoredTrial(productID: "com.app.pro", originalTransactionID: 7) + tracker.saveTrials(["com.app.pro": trial]) + + let claimed = tracker.claimTrial(productID: "com.app.pro") + + #expect(claimed?.originalTransactionID == 7) + #expect(tracker.loadTrials().isEmpty) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func claimTrial_whenAlreadyClaimedOrUnknown_returnsNil() { + let (tracker, _) = isolatedTracker() + + let trial = TrialConversionTracker.StoredTrial(productID: "com.app.pro", originalTransactionID: 7) + tracker.saveTrials(["com.app.pro": trial]) + + #expect(tracker.claimTrial(productID: "com.app.pro") != nil) + #expect(tracker.claimTrial(productID: "com.app.pro") == nil) + #expect(tracker.claimTrial(productID: "com.app.unknown") == nil) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func claimTrial_calledConcurrently_yieldsExactlyOneWinner() { + let (tracker, _) = isolatedTracker() + + let trial = TrialConversionTracker.StoredTrial(productID: "com.app.pro", originalTransactionID: 7) + tracker.saveTrials(["com.app.pro": trial]) + + let winnerCount = ClaimWinnerCounter() + DispatchQueue.concurrentPerform(iterations: 20) { _ in + if tracker.claimTrial(productID: "com.app.pro") != nil { + winnerCount.increment() + } + } + + #expect(winnerCount.value == 1) + } + + // MARK: - Outcome classification + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_stillOnTrial_whenActiveAndNotExpired() { + let outcome = TrialConversionTracker.classify(isRevoked: false, isUpgraded: false, isFreeTrial: true, isExpired: false) + #expect(outcome == .stillOnTrial) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_convertedToPaid_whenNoLongerOnTrial() { + let outcome = TrialConversionTracker.classify(isRevoked: false, isUpgraded: false, isFreeTrial: false, isExpired: false) + #expect(outcome == .convertedToPaid) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_convertedToPaid_evenWhenThePaidPeriodHasSinceExpired() { + let outcome = TrialConversionTracker.classify(isRevoked: false, isUpgraded: false, isFreeTrial: false, isExpired: true) + #expect(outcome == .convertedToPaid) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_cancelledOrExpired_whenTrialLapsedWithoutConverting() { + let outcome = TrialConversionTracker.classify(isRevoked: false, isUpgraded: false, isFreeTrial: true, isExpired: true) + #expect(outcome == .cancelledOrExpired) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_cancelledOrExpired_whenRevoked() { + let outcome = TrialConversionTracker.classify(isRevoked: true, isUpgraded: false, isFreeTrial: false, isExpired: false) + #expect(outcome == .cancelledOrExpired) + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @Test + func classify_cancelledOrExpired_whenUpgraded() { + let outcome = TrialConversionTracker.classify(isRevoked: false, isUpgraded: true, isFreeTrial: true, isExpired: false) + #expect(outcome == .cancelledOrExpired) + } +} + +/// A thread-safe counter used to verify that only one of several concurrent `claimTrial` calls succeeds. +private final class ClaimWinnerCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } +} + +/// An in-memory `UserDefaults` double that never touches disk, used to isolate `TrialConversionTracker` tests +/// from both real preferences files and the app-wide `TelemetryManager` singleton. +private final class InMemoryUserDefaults: UserDefaults, @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Any] = [:] + + init() { + super.init(suiteName: nil)! + } + + override func data(forKey defaultName: String) -> Data? { + lock.lock() + defer { lock.unlock() } + return storage[defaultName] as? Data + } + + override func set(_ value: Any?, forKey defaultName: String) { + lock.lock() + storage[defaultName] = value + lock.unlock() + } + + override func removeObject(forKey defaultName: String) { + lock.lock() + storage.removeValue(forKey: defaultName) + lock.unlock() + } +}