mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* 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>
462 lines
16 KiB
Swift
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"
|
|
}
|
|
}
|
|
|
|
}
|