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