Files
purchases-ios/Sources/Networking/Operations/PostReceiptDataOperation.swift
T
13212e20f1 Track paywall source for Customer Center purchases (#5691)
* Track customer center purchases

* simplified logic

* add spiinternal

* fixed tests

* pod lib lint

* Add PaywallSource.swift to RevenueCatUI target

* Remove unnecessary `@_spi(Internal)` attributes

* Simplified Customer Center paywall source to `customer_center`

* Remove redundant `source` parameter from `track` method

* Update snapshot files

* Move PaywallSource to RevenueCat module as @_spi(Internal) enum

PaywallSource is now a String-backed enum in the core SDK module
(alongside PaywallViewMode), with PaywallEvent.Data.source typed as
PaywallSource? instead of String?. Public inits without source are
deprecated in favor of @_spi(Internal) inits that accept the typed
source parameter.

Made-with: Cursor

* Fix: pass `paywallSource` to `PaywallEvent` creation

* Cleanup unnecessary diff

* Revert changes to Examples/rc-maestro/rc-maestro/Resources/StoreKit/StoreKitConfiguration.storekit

* Remove redundant source parameter from trackPaywallImpression

The source is already embedded in PaywallEvent.Data by createEventData(),
so passing it separately was redundant. Callers no longer need to forward
paywallSource explicitly.

Made-with: Cursor

* Undo changes to RevenueCatUI/Purchasing/MockPurchases.swift

* Undo changes to RevenueCatUI/Purchasing/PurchaseHandler.swift

* Remove presentedOfferingSource from receipt posting

This top-level field will be added in a separate PR for the
non-paywall Customer Center purchase path. For now, source is
only sent inside the paywall object.

Made-with: Cursor

* Remove trailing newlines from snapshot JSON files

Restores the original file endings to avoid potential test
comparison issues when files are opened in Xcode.

Made-with: Cursor

* Simplification

* Remove trailing newlines from OfferingContext snapshot files

Restores the original file endings to match main and avoid
spurious last-line diffs in the PR.

Made-with: Cursor

* Change Paywall.source type from String? to PaywallSource?

Uses the strongly-typed enum directly instead of its raw value,
letting Codable handle the string encoding automatically.

Made-with: Cursor

* Revert cosmetic changes to PurchaseHandler+TestData.swift

Restores original formatting from main; the diff was purely
noise (reformatting + redundant default parameter).

Made-with: Cursor

* Change EventsRequest.PaywallEvent.source type from String? to PaywallSource?

Consistent with PostReceiptDataOperation.Paywall.source, uses the
strongly-typed enum directly and lets Codable handle serialization.

Made-with: Cursor

* Cleanup

* Revert cosmetic changes in AsyncPurchaseHandler init

Restores original trailing closure syntax to reduce PR noise.

Made-with: Cursor

* Add snapshot test for PaywallEvent source serialization

Verifies that the source field is correctly encoded as
"customer_center" in the EventsRequest.PaywallEvent payload.

Made-with: Cursor

* Update deprecation messages for PaywallEvent.Data inits

Replace implementation-specific deprecation messages with a generic
"This initializer will be removed in a future version." message.

Made-with: Cursor

* Fix compilation error, add source: nil test coverage, fix snapshot newline

- Add @_spi(Internal) import to PurchaseHandlerTests so the `source`
  parameter on the @_spi(Internal) PaywallEvent.Data init is visible.
- Restore testPostsReceiptDataWithPresentedPaywall to use source: nil
  (the common case) so we verify the source key is absent from JSON.
- Add testPostsReceiptDataWithPresentedPaywallAndSource for the
  source: .customerCenter case with its own snapshots.
- Fix missing trailing newline in testImpressionEventWithSource snapshot.

Made-with: Cursor

* Fix snapshot test trailing newlines causing test failures

Remove spurious trailing newlines from 11 snapshot JSON files.
The snapshot library produces output without a final newline, so
the extra byte caused every comparison to fail.

Made-with: Cursor

* Fix exhaustive switch on PaywallEvent in PurchaseHandlerTests

Add missing cases for exitOffer, purchaseInitiated, and purchaseError.

Made-with: Cursor

* Fix PaywallSource being dropped by withPurchaseInfo

withPurchaseInfo was creating a new PaywallEvent.Data without forwarding
self.source, so the source was silently lost for purchaseInitiated,
cancel, and purchaseError events. This meant the POST receipt paywall.source
field was always nil even when the paywall was presented from Customer Center.

Also remove the default nil for the source parameter in the internal init
to prevent similar issues in the future.

Made-with: Cursor

* fix: Add missing `source` parameter to PaywallEvent.Data init in tests

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

---------

Co-authored-by: Antonio Pallares <antonio.pallares@revenuecat.com>
Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:10:39 +00:00

446 lines
15 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?
}
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)
}
}
// 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 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.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
}
}
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"
}
}
}