Files
Facundo MenzellaandClaude Opus 5.5 3d03673f61 other: attribute checkpoint offering purchases to their hit (#8058)
* Attribute checkpoint offering purchases to the hit trace id

Put the hit's trace id on the matched offering's presented context and send it as presented_trace_id on receipts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Require traceID in withCheckpointIdentifier

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Use the checkpoint trace id for the offering's workflow

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Carry the hit trace id on matchedOffering

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Require traceID in withPresentedCheckpointIdentifier and tighten trace id tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Create the matched offering and its trace id in one place

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:53:46 +02:00

486 lines
18 KiB
Swift

//
// 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
//
// PostReceiptDataOperation.swift
//
// Created by Joshua Liebowitz on 11/18/21.
import Foundation
// swiftlint:disable file_length
final class PostReceiptDataOperation: CacheableNetworkOperation {
private let postData: PostData
private let configuration: AppUserConfiguration
private let customerInfoResponseHandler: CustomerInfoResponseHandler
private let customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>
private let subscriberDimensionsStore: SubscriberDimensionsStoreType
static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?,
subscriberDimensionsStore: SubscriberDimensionsStoreType
) -> CacheableNetworkOperationFactory<PostReceiptDataOperation> {
return Self.createFactory(
configuration: configuration,
postData: postData,
customerInfoResponseHandler: .init(
offlineCreator: offlineCustomerInfoCreator,
userID: configuration.appUserID,
failIfInvalidSubscriptionKeyDetectedInDebug: true
),
customerInfoCallbackCache: customerInfoCallbackCache,
subscriberDimensionsStore: subscriberDimensionsStore
)
}
static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
subscriberDimensionsStore: SubscriberDimensionsStoreType
) -> CacheableNetworkOperationFactory<PostReceiptDataOperation> {
/// Cache key comprises of the following:
/// - `appUserID`
/// - `isRestore`
/// - Receipt (`hashString` instead of `fetchToken` to avoid big receipts leading to a huge cache key)
/// - `ProductRequestData.cacheKey`
/// - `presentedOfferingIdentifier`
/// - `presentedCheckpointIdentifier`
/// - `presentedTraceID`
/// - `observerMode`
/// - `subscriberAttributesByKey`
/// - `sdkOriginated`
/// - `transactionId` (only if there is attribution data, to always post receipts with attribution data)
let cacheKey =
"""
\(configuration.appUserID)-\(postData.isRestore)-\(postData.receipt.hash)
-\(postData.productData?.cacheKey ?? "")
-\(postData.presentedOfferingIdentifier ?? "")-\(postData.presentedCheckpointIdentifier ?? "")
-\(postData.presentedTraceID ?? "")-\(postData.observerMode)
-\(postData.subscriberAttributesByKey?.individualizedCacheKeyPart ?? "")
-\(postData.sdkOriginated)
-\(postData.containsAttributionData ? (postData.transactionId ?? "") : "")
"""
return .init({ cacheKey in
.init(
configuration: configuration,
postData: postData,
customerInfoResponseHandler: customerInfoResponseHandler,
customerInfoCallbackCache: customerInfoCallbackCache,
subscriberDimensionsStore: subscriberDimensionsStore,
cacheKey: cacheKey
)
},
individualizedCacheKeyPart: cacheKey
)
}
private init(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
subscriberDimensionsStore: SubscriberDimensionsStoreType,
cacheKey: String
) {
self.customerInfoResponseHandler = customerInfoResponseHandler
self.customerInfoCallbackCache = customerInfoCallbackCache
self.postData = postData
self.configuration = configuration
self.subscriberDimensionsStore = subscriberDimensionsStore
super.init(configuration: configuration, cacheKey: cacheKey)
}
override func begin(completion: @escaping () -> Void) {
if Logger.logLevel <= .debug {
self.printReceiptData()
}
self.post(completion: completion)
}
private func post(completion: @escaping () -> Void) {
let request = HTTPRequest(method: .post(self.postData), path: .postReceiptData, isRetryable: true)
self.httpClient.perform(
request
) { (response: VerifiedHTTPResponse<CustomerInfoResponseHandler.Response>.Result) in
if case let .success(response) = response {
self.subscriberDimensionsStore.store(
response.body.customerInfo,
appUserID: self.configuration.appUserID
)
}
self.customerInfoResponseHandler.handle(customerInfoResponse: response) { result in
self.customerInfoCallbackCache.performOnAllItemsAndRemoveFromCache(
withCacheable: self
) { callbackObject in
callbackObject.completion(result)
}
}
completion()
}
}
}
// Restating inherited @unchecked Sendable from Foundation's Operation
extension PostReceiptDataOperation: @unchecked Sendable {}
extension PostReceiptDataOperation {
struct PostData {
/// Version of the payload format sent to the backend.
/// - Important: Keep in sync with purchases-android.
static let payloadVersion: Int = 1
let appUserID: String
let receipt: EncodedAppleReceipt
let isRestore: Bool
let productData: ProductRequestData?
let presentedOfferingIdentifier: String?
let presentedPlacementIdentifier: String?
let presentedCheckpointIdentifier: String?
let presentedTraceID: String?
let appliedTargetingRule: AppliedTargetingRule?
let paywall: Paywall?
/// The value of observer mode at the time of the request.
let observerMode: Bool
/// The value of purchaseCompletedBy at purchase time.
let purchaseCompletedBy: PurchasesAreCompletedBy?
let initiationSource: PostReceiptSource.InitiationSource
let subscriberAttributesByKey: SubscriberAttribute.Dictionary?
let aadAttributionToken: String?
/// - Note: this is only used for the backend to disambiguate receipts created in `SKTestSession`s.
let testReceiptIdentifier: String?
/// The [AppTransaction](https://developer.apple.com/documentation/storekit/apptransaction) JWS token
/// retrieved from StoreKit 2.
let appTransaction: String?
let transactionId: String?
/// Indicates whether this purchase was initiated via the SDK's `purchase()` methods.
let sdkOriginated: Bool
let metadata: [String: String]?
let containsAttributionData: Bool
}
struct Paywall {
var paywallID: String?
var sessionID: String
var revision: Int
var displayMode: PaywallViewMode
var darkMode: Bool
var localeIdentifier: String
var source: PaywallSource?
// Sent at the top level of the post-receipt body as `presented_workflow_id`/`presented_step_id`,
// not inside the nested `paywall` object — excluded from Codable via the CodingKeys enum below.
var workflowId: String?
var stepId: String?
/// Encoded inside the nested paywall object for transaction attribution.
var traceId: String?
}
struct AppliedTargetingRule {
var revision: Int
var ruleId: String
}
}
extension PostReceiptDataOperation.PostData {
init(
transactionData data: PurchasedTransactionData,
postReceiptSource: PostReceiptSource,
appUserID: String,
productData: ProductRequestData?,
receipt: EncodedAppleReceipt,
observerMode: Bool,
purchaseCompletedBy: PurchasesAreCompletedBy?,
testReceiptIdentifier: String?,
appTransaction: String?,
transactionId: String?,
/// Whether it contains attribution data for `transactionId`. This field is not included in the request
containsAttributionData: Bool,
sdkOriginated: Bool = false
) {
self.init(
appUserID: appUserID,
receipt: receipt,
isRestore: postReceiptSource.isRestore,
productData: productData,
presentedOfferingIdentifier: data.presentedOfferingContext?.offeringIdentifier,
presentedPlacementIdentifier: data.presentedOfferingContext?.placementIdentifier,
presentedCheckpointIdentifier: data.presentedOfferingContext?.checkpointIdentifier,
presentedTraceID: data.presentedOfferingContext?.traceID,
appliedTargetingRule: data.presentedOfferingContext?.targetingContext.flatMap {
.init(revision: $0.revision, ruleId: $0.ruleId)
},
paywall: data.paywall,
observerMode: observerMode,
purchaseCompletedBy: purchaseCompletedBy,
initiationSource: postReceiptSource.initiationSource,
subscriberAttributesByKey: data.unsyncedAttributes,
aadAttributionToken: data.aadAttributionToken,
testReceiptIdentifier: testReceiptIdentifier,
appTransaction: appTransaction,
transactionId: transactionId,
sdkOriginated: sdkOriginated,
metadata: data.metadata,
containsAttributionData: containsAttributionData
)
}
}
private extension PurchasedTransactionData {
var paywall: PostReceiptDataOperation.Paywall? {
guard let paywall = self.presentedPaywall else { return nil }
return .init(paywallID: paywall.data.paywallIdentifier,
sessionID: paywall.data.sessionIdentifier.uuidString,
revision: paywall.data.paywallRevision,
displayMode: paywall.data.displayMode,
darkMode: paywall.data.darkMode,
localeIdentifier: paywall.data.localeIdentifier,
source: paywall.data.source,
workflowId: paywall.data.workflowId,
stepId: paywall.data.stepId,
traceId: paywall.data.traceId)
}
}
// MARK: - Private
private extension PostReceiptDataOperation {
func printReceiptData() {
guard self.postData.receipt != .empty else { return }
switch self.postData.receipt {
case .jws(let content):
self.log(Strings.receipt.posting_jws(
content,
initiationSource: self.postData.initiationSource.rawValue
))
case .sk2receipt(let receipt):
self.log(Strings.receipt.posting_sk2_receipt(
(try? receipt.prettyPrintedJSON) ?? "",
initiationSource: self.postData.initiationSource.rawValue
))
case .receipt(let data):
do {
let receipt = try PurchasesReceiptParser.default.parse(from: data)
self.log(Strings.receipt.posting_receipt(
receipt,
initiationSource: self.postData.initiationSource.rawValue
))
for purchase in receipt.inAppPurchases where purchase.purchaseDateEqualsExpiration {
Logger.appleError(Strings.receipt.receipt_subscription_purchase_equals_expiration(
productIdentifier: purchase.productId,
purchase: purchase.purchaseDate,
expiration: purchase.expiresDate
))
}
} catch {
Logger.appleError(Strings.receipt.parse_receipt_locally_error(error: error))
}
case .empty:
return
}
}
}
// MARK: - Codable
extension PostReceiptDataOperation.PostData: Encodable {
private enum CodingKeys: String, CodingKey {
case payloadVersion = "payload_version"
case fetchToken = "fetch_token"
case appUserID = "app_user_id"
case isRestore
case observerMode
case purchaseCompletedBy = "purchase_completed_by"
case initiationSource
case attributes
case aadAttributionToken
case presentedOfferingIdentifier
case presentedPlacementIdentifier
case presentedCheckpointIdentifier
case presentedTraceID = "presented_trace_id"
case appliedTargetingRule
case paywall
case presentedWorkflowId = "presented_workflow_id"
case presentedStepId = "presented_step_id"
case testReceiptIdentifier = "test_receipt_identifier"
case appTransaction = "app_transaction"
case transactionId = "transaction_id"
case sdkOriginated = "sdk_originated"
case metadata
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.payloadVersion, forKey: .payloadVersion)
try container.encode(self.appUserID, forKey: .appUserID)
try container.encode(self.isRestore, forKey: .isRestore)
try container.encode(self.observerMode, forKey: .observerMode)
try container.encode(self.initiationSource, forKey: .initiationSource)
if let productData = self.productData {
try productData.encode(to: encoder)
}
try container.encodeIfPresent(self.fetchToken, forKey: .fetchToken)
try container.encodeIfPresent(self.appTransaction, forKey: .appTransaction)
try container.encodeIfPresent(self.transactionId, forKey: .transactionId)
try container.encode(self.sdkOriginated, forKey: .sdkOriginated)
try container.encodeIfPresent(self.metadata, forKey: .metadata)
try container.encodeIfPresent(self.presentedOfferingIdentifier, forKey: .presentedOfferingIdentifier)
try container.encodeIfPresent(self.presentedPlacementIdentifier, forKey: .presentedPlacementIdentifier)
try container.encodeIfPresent(self.presentedCheckpointIdentifier, forKey: .presentedCheckpointIdentifier)
try container.encodeIfPresent(self.presentedTraceID, forKey: .presentedTraceID)
try container.encodeIfPresent(self.appliedTargetingRule, forKey: .appliedTargetingRule)
try container.encodeIfPresent(self.paywall, forKey: .paywall)
try container.encodeIfPresent(self.paywall?.workflowId, forKey: .presentedWorkflowId)
try container.encodeIfPresent(self.paywall?.stepId, forKey: .presentedStepId)
try container.encodeIfPresent(self.purchaseCompletedBy?.name, forKey: .purchaseCompletedBy)
try container.encodeIfPresent(
self.subscriberAttributesByKey
.map(SubscriberAttribute.map)
.map(AnyEncodable.init),
forKey: .attributes
)
try container.encodeIfPresent(self.aadAttributionToken, forKey: .aadAttributionToken)
try container.encodeIfPresent(self.testReceiptIdentifier, forKey: .testReceiptIdentifier)
}
var fetchToken: String? { return self.receipt.serialized() }
}
extension PostReceiptDataOperation.Paywall: Codable {
private enum CodingKeys: String, CodingKey {
case paywallID = "paywallId"
case sessionID = "sessionId"
case revision
case displayMode
case darkMode
case localeIdentifier = "locale"
case source
case traceId
}
}
extension PostReceiptDataOperation.AppliedTargetingRule: Codable {
private enum CodingKeys: String, CodingKey {
case revision
case ruleId
}
}
// MARK: - HTTPRequestBody
extension PostReceiptDataOperation.PostData: HTTPRequestBody {
var contentForSignature: [(key: String, value: String?)] {
return [
(Self.CodingKeys.appUserID.stringValue, self.appUserID),
(Self.CodingKeys.fetchToken.stringValue, self.fetchToken),
(Self.CodingKeys.appTransaction.stringValue, self.appTransaction)
]
}
}
// MARK: - InitiationSource
extension PostReceiptSource.InitiationSource: Codable, RawRepresentable {
var rawValue: String {
switch self {
case .restore: return "restore"
case .purchase: return "purchase"
case .queue: return "queue"
}
}
init?(rawValue: String) {
guard let value = Self.codes[rawValue] else { return nil }
self = value
}
private static let codes: [String: PostReceiptSource.InitiationSource] = Self
.allCases
.dictionaryWithKeys { $0.rawValue }
}
// MARK: - EncodedAppleReceipt
private extension EncodedAppleReceipt {
var hash: String {
switch self {
case let .jws(content):
return content.asData.hashString
case let .receipt(data):
return data.hashString
case let .sk2receipt(receipt):
do {
return try receipt.prettyPrintedData.hashString
} catch {
Logger.warn(Strings.storeKit.sk2_error_encoding_receipt(error))
return ""
}
case .empty:
return "empty"
}
}
}
private extension PurchasesAreCompletedBy {
var name: String {
switch self {
case .revenueCat: return "revenuecat"
case .myApp: return "my_app"
}
}
}