Files
purchases-ios/Sources/Networking/Operations/PostReceiptDataOperation.swift
T
Cesar de la VegaandClaude ff6d7f82db Send paywall_id and trace_id on post receipt (#7311)
* fix: report the paywall id on workflow paywalls

* fix: read the workflow paywall id from the screen key

Reading it off the offering could never work: workflows imply remote config
is active, and OfferingsManager.shouldCreatePaywallComponents then decodes
offerings without their paywall components, so both branches of
paywallId(from:) were always nil.

The workflow config keys each screen by its own paywall id, so use the
step's screenId, which is already in scope at both call sites. This is also
per-screen rather than per-offering, so multi-screen workflows report the
right paywall for each step.

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

* feat: send the workflow trace id in the post-receipt body

#7304 added trace_id to paywall events only, so attributing a transaction to
a workflow traversal required joining paywall events to workflow events.
Sending it on the receipt puts it in transactions.presented_offering_context
alongside paywall_id/workflow_id/workflow_step_id instead.

It goes inside the nested paywall object rather than as a top-level
presented_trace_id sibling of presented_workflow_id: the backend's
post-receipt body model forbids unknown top-level keys, so a new one would
fail every purchase until the backend deploys. The nested paywall model
ignores keys it does not know yet, so this is inert until then.

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

* feat: carry workflow trace into purchase attribution

* feat: attach workflow trace to paywall purchases

* feat: send workflow trace in post-receipt data

* fix: report workflow paywall IDs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 12:49:37 +02:00

462 lines
16 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>
static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?
) -> CacheableNetworkOperationFactory<PostReceiptDataOperation> {
return Self.createFactory(
configuration: configuration,
postData: postData,
customerInfoResponseHandler: .init(
offlineCreator: offlineCustomerInfoCreator,
userID: configuration.appUserID,
failIfInvalidSubscriptionKeyDetectedInDebug: true
),
customerInfoCallbackCache: customerInfoCallbackCache
)
}
static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>
) -> 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`
/// - `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.observerMode)
-\(postData.subscriberAttributesByKey?.individualizedCacheKeyPart ?? "")
-\(postData.sdkOriginated)
-\(postData.containsAttributionData ? (postData.transactionId ?? "") : "")
"""
return .init({ cacheKey in
.init(
configuration: configuration,
postData: postData,
customerInfoResponseHandler: customerInfoResponseHandler,
customerInfoCallbackCache: customerInfoCallbackCache,
cacheKey: cacheKey
)
},
individualizedCacheKeyPart: cacheKey
)
}
private init(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
cacheKey: String
) {
self.customerInfoResponseHandler = customerInfoResponseHandler
self.customerInfoCallbackCache = customerInfoCallbackCache
self.postData = postData
self.configuration = configuration
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
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 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,
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 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.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"
}
}
}