Skip to content

Commit

Permalink
[SK2] Add support for StoreKit Config files in SK2 (#3365)
Browse files Browse the repository at this point in the history
When using StoreKit Config file, we do not have access to the
transaction history or subscription status server-side.

To work around this, we introduce a new `StoreKit2Receipt` type that
closely resembles the structure of an SK1 receipt and contains all the
transaction history and subscription status for the customer. It can be
sent in a single request to `/receipt`

---------

Co-authored-by: RevenueCat Git Bot <[email protected]>
Co-authored-by: Distiller <[email protected]>
Co-authored-by: Distiller <[email protected]>
Co-authored-by: Distiller <[email protected]>
Co-authored-by: NachoSoto <[email protected]>
  • Loading branch information
6 people authored Dec 13, 2023
1 parent d3eb419 commit 32c63cd
Show file tree
Hide file tree
Showing 42 changed files with 1,222 additions and 78 deletions.
16 changes: 16 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@
37E3578711F5FDD5DC6458A8 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3521731D8DC16873F55F3 /* AttributionFetcher.swift */; };
37E35C8515C5E2D01B0AF5C1 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3507939634ED5A9280544 /* Strings.swift */; };
42F1DF385E3C1F9903A07FBF /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB3CBAA73855779FE828CE2 /* ProductsFetcherSK1.swift */; };
4D6ABB0C2AF13F9400BB2A08 /* StoreKit2Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6ABB0B2AF13F9400BB2A08 /* StoreKit2Receipt.swift */; };
4D6ABB0E2AF13FB100BB2A08 /* StoreEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6ABB0D2AF13FB100BB2A08 /* StoreEnvironment.swift */; };
4D6ABB102AF13FBD00BB2A08 /* SK2AppTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6ABB0F2AF13FBD00BB2A08 /* SK2AppTransaction.swift */; };
4D72E8622B221EA600BF9EFE /* StoreEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D24EF3F2B04EA6000E586D2 /* StoreEnvironmentTests.swift */; };
4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */; };
4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; };
4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */; };
Expand Down Expand Up @@ -990,6 +994,10 @@
37E35EEE7783629CDE41B70C /* SystemInfoTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemInfoTests.swift; sourceTree = "<group>"; };
37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductsRequestFactory.swift; sourceTree = "<group>"; };
37E35FDA0A44EA03EA12DAA2 /* DateFormatter+ExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DateFormatter+ExtensionsTests.swift"; sourceTree = "<group>"; };
4D24EF3F2B04EA6000E586D2 /* StoreEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreEnvironmentTests.swift; sourceTree = "<group>"; };
4D6ABB0B2AF13F9400BB2A08 /* StoreKit2Receipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreKit2Receipt.swift; sourceTree = "<group>"; };
4D6ABB0D2AF13FB100BB2A08 /* StoreEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreEnvironment.swift; sourceTree = "<group>"; };
4D6ABB0F2AF13FBD00BB2A08 /* SK2AppTransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SK2AppTransaction.swift; sourceTree = "<group>"; };
4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncodedAppleReceipt.swift; sourceTree = "<group>"; };
4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = "<group>"; };
4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallDataTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1510,9 +1518,11 @@
006E105DDA8D0D0FA32D690C /* StoreKit2 */ = {
isa = PBXGroup;
children = (
4D6ABB0F2AF13FBD00BB2A08 /* SK2AppTransaction.swift */,
3628C1F100BB3C1782860D24 /* ProductsFetcherSK2.swift */,
A55D082F2722368600D919E0 /* SK2BeginRefundRequestHelper.swift */,
F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */,
4D6ABB0B2AF13F9400BB2A08 /* StoreKit2Receipt.swift */,
2D294E5B26DECFD500B8FE4F /* StoreKit2TransactionListener.swift */,
4F7DBFBC2A1E986C00A2F511 /* StoreKit2TransactionFetcher.swift */,
);
Expand Down Expand Up @@ -1561,6 +1571,7 @@
57C381DB27961547009E3940 /* SK2StoreProductDiscount.swift */,
57536A27278522B400E2AE7F /* SK2StoreTransaction.swift */,
57DE806C28074976008D6C6F /* Storefront.swift */,
4D6ABB0D2AF13FB100BB2A08 /* StoreEnvironment.swift */,
573E7F082819B989007C9128 /* StoreKitWorkarounds.swift */,
FECF627761D375C8431EB866 /* StoreProduct.swift */,
B372EC53268FEDC60099171E /* StoreProductDiscount.swift */,
Expand All @@ -1576,6 +1587,7 @@
children = (
2D1015E0275A676F0086173F /* SubscriptionPeriodTests.swift */,
FD18ED4D2837F89200C5AA4F /* StoreKitWorkaroundsTests.swift */,
4D24EF3F2B04EA6000E586D2 /* StoreEnvironmentTests.swift */,
5733D01028D00354008638D8 /* PromotionalOfferTests.swift */,
);
path = StoreKitAbstractions;
Expand Down Expand Up @@ -3355,6 +3367,7 @@
B3A36AAE26BC76340059EDEA /* CustomerInfoManager.swift in Sources */,
F5FCD3EA27DA0D0B003BDC04 /* PriceFormatterProvider.swift in Sources */,
B3083A132699334C007B5503 /* Offering.swift in Sources */,
4D6ABB0E2AF13FB100BB2A08 /* StoreEnvironment.swift in Sources */,
2DD58DD827F240EB000FDFE3 /* EmptyFile.swift in Sources */,
2CD72942268A823900BFC976 /* Data+Extensions.swift in Sources */,
4FD3688B2AA7C12600F63354 /* PaywallEvent.swift in Sources */,
Expand Down Expand Up @@ -3494,6 +3507,7 @@
F5714EAC26D7A87B00635477 /* PurchaseOwnershipType+Extensions.swift in Sources */,
57CD86DA291C1E2300768DE1 /* UserDefaults+Extensions.swift in Sources */,
4FD368B62AA7D09C00F63354 /* PaywallEventSerializer.swift in Sources */,
4D6ABB0C2AF13F9400BB2A08 /* StoreKit2Receipt.swift in Sources */,
F5BE424026962ACF00254A30 /* ReceiptRefreshPolicy.swift in Sources */,
9A65E0762591977200DE00B0 /* IdentityStrings.swift in Sources */,
4F6ABC782A81595900250E63 /* PaywallCacheWarming.swift in Sources */,
Expand Down Expand Up @@ -3567,6 +3581,7 @@
5746508C27586B2E0053AB09 /* Result+Extensions.swift in Sources */,
B34D2AA0269606E400D88C3A /* IntroEligibility.swift in Sources */,
F516BD29282434070083480B /* StoreKit2StorefrontListener.swift in Sources */,
4D6ABB102AF13FBD00BB2A08 /* SK2AppTransaction.swift in Sources */,
5766AAB0283D8CDC00FA6091 /* CacheFetchPolicy.swift in Sources */,
4FD368B42AA7CFED00F63354 /* PaywallEventStore.swift in Sources */,
B302206E2728B798008F1A0D /* BackendErrorStrings.swift in Sources */,
Expand Down Expand Up @@ -3832,6 +3847,7 @@
5722482727C2BD3200C524A7 /* LockTests.swift in Sources */,
351B514526D449E600BD2BD7 /* MockAttributionTypeFactory.swift in Sources */,
4FE0685F2A5F54C500B8F56C /* PackageTypeTests.swift in Sources */,
4D72E8622B221EA600BF9EFE /* StoreEnvironmentTests.swift in Sources */,
575A8EE12922C56300936709 /* AsyncTestHelpers.swift in Sources */,
572247F727BF1ADF00C524A7 /* ArrayExtensionsTests.swift in Sources */,
F55FFA5A27634C3F00995146 /* MockTransactionsManager.swift in Sources */,
Expand Down
38 changes: 38 additions & 0 deletions Sources/FoundationExtensions/AsyncExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,44 @@ internal enum Async {
}
}

/// Runs the given block `maximumRetries` times at most, at `pollInterval` times until the
/// block returns a tuple where the first argument `shouldRetry` is false, and the second is the expected value.
/// After the maximum retries, returns the last seen value.
///
/// Example:
/// ```swift
/// let receipt = await Async.retry {
/// let receipt = fetchReceipt()
/// if receipt.contains(transaction) {
/// return (shouldRetry: false, receipt)
/// } else {
/// return (shouldRetry: true, receipt)
/// }
/// }
/// ```
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
static func retry<T>(
maximumRetries: Int = 5,
pollInterval: DispatchTimeInterval = .milliseconds(300),
until value: @Sendable () async -> (shouldRetry: Bool, result: T)
) async -> T {
var lastValue: T
var retries = 0

repeat {
retries += 1
let (shouldRetry, result) = await value()
if shouldRetry {
lastValue = result
try? await Task.sleep(nanoseconds: UInt64(pollInterval.nanoseconds))
} else {
return result
}
} while !(retries > maximumRetries)

return lastValue
}

}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
Expand Down
4 changes: 4 additions & 0 deletions Sources/LocalReceiptParsing/Helpers/ReceiptStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum ReceiptStrings {
case unable_to_load_receipt(Error)
case posting_receipt(AppleReceipt, initiationSource: String)
case posting_jws(String, initiationSource: String)
case posting_sk2_receipt(String, initiationSource: String)
case receipt_subscription_purchase_equals_expiration(
productIdentifier: String,
purchase: Date,
Expand Down Expand Up @@ -95,6 +96,9 @@ extension ReceiptStrings: LogMessage {
case let .posting_jws(token, initiationSource):
return "Posting JWS token (source: '\(initiationSource)'):\n\(token)"

case let .posting_sk2_receipt(receipt, initiationSource):
return "Posting StoreKit 2 receipt (source: '\(initiationSource)'):\n\(receipt)"

case let .receipt_subscription_purchase_equals_expiration(
productIdentifier,
purchase,
Expand Down
35 changes: 35 additions & 0 deletions Sources/Logging/Strings/StoreKitStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ enum StoreKitStrings {

case sk2_observing_transaction_updates

case sk2_unknown_environment(String)

case sk2_error_encoding_receipt(Error)

case sk2_error_fetching_app_transaction(Error)

case sk2_error_fetching_subscription_status(subscriptionGroupId: String, Error)

case sk2_app_transaction_unavailable

case sk2_unverified_transaction(identifier: String, Error)

case sk2_receipt_missing_purchase(transactionId: String)

#if DEBUG

case sk1_wrapper_notifying_delegate_of_existing_transactions(count: Int)
Expand Down Expand Up @@ -149,6 +163,27 @@ extension StoreKitStrings: LogMessage {
case .sk2_observing_transaction_updates:
return "Observing StoreKit.Transaction.updates"

case let .sk2_unknown_environment(environment):
return "Unrecognized StoreKit Environment: \(environment)"

case let .sk2_error_encoding_receipt(error):
return "Error encoding SK2 receipt: '\(error)'"

case let .sk2_error_fetching_app_transaction(error):
return "Error fetching AppTransaction: '\(error)'"

case let .sk2_error_fetching_subscription_status(subscriptionGroupId, error):
return "Error fetching status for subscription group with id '\(subscriptionGroupId)': '\(error)'"

case .sk2_app_transaction_unavailable:
return "Not fetching AppTransaction because it is not available"

case let .sk2_unverified_transaction(id, error):
return "Found unverified transaction with ID: '\(id)' Error: '\(error)'"

case let .sk2_receipt_missing_purchase(transactionId):
return "SK2 receipt is still missing transaction with id '\(transactionId)'"

#if DEBUG
case let .sk1_wrapper_notifying_delegate_of_existing_transactions(count):
return "StoreKit1Wrapper: sending delegate \(count) existing transactions " +
Expand Down
1 change: 1 addition & 0 deletions Sources/Misc/Deprecations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ extension CustomerInfo {
let quantity: Int
var storefront: Storefront? { return nil }
internal var jwsRepresentation: String? { return nil }
internal var environment: StoreEnvironment? { return nil }

var hasKnownPurchaseDate: Bool { true }
var hasKnownTransactionIdentifier: Bool { return true }
Expand Down
12 changes: 12 additions & 0 deletions Sources/Networking/Operations/PostReceiptDataOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ private extension PostReceiptDataOperation {
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)
Expand Down Expand Up @@ -332,6 +337,13 @@ private extension EncodedAppleReceipt {
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 ""
}
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
let transactionPoster = TransactionPoster(
productsManager: productsManager,
receiptFetcher: receiptFetcher,
transactionFetcher: transactionFetcher,
backend: backend,
paymentQueueWrapper: paymentQueueWrapper,
systemInfo: systemInfo,
Expand Down
17 changes: 15 additions & 2 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,8 @@ private extension PurchasesOrchestrator {
return
}

let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation)

self.createProductRequestData(with: transaction.productIdentifier) { productRequestData in
let transactionData: PurchasedTransactionData = .init(
appUserID: currentAppUserID,
Expand All @@ -1111,7 +1113,7 @@ private extension PurchasesOrchestrator {
source: .init(isRestore: isRestore, initiationSource: initiationSource)
)

self.backend.post(receipt: .jws(jwsRepresentation),
self.backend.post(receipt: receipt,
productData: productRequestData,
transactionData: transactionData,
observerMode: self.observerMode) { result in
Expand Down Expand Up @@ -1309,11 +1311,13 @@ private extension PurchasesOrchestrator {
return
}

let receipt = await self.encodedReceipt(transaction: transaction, jwsRepresentation: jwsRepresentation)

self.handlePromotionalOffer(forProductDiscount: productDiscount,
discountIdentifier: discountIdentifier,
product: product,
subscriptionGroupIdentifier: subscriptionGroupIdentifier,
receipt: .jws(jwsRepresentation)) { result in
receipt: receipt) { result in
completion(result)
}
}
Expand Down Expand Up @@ -1386,6 +1390,15 @@ private extension PurchasesOrchestrator {

private extension PurchasesOrchestrator {

@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func encodedReceipt(transaction: StoreTransactionType, jwsRepresentation: String) async -> EncodedAppleReceipt {
if transaction.environment == .xcode {
return .sk2receipt(await self.transactionFetcher.fetchReceipt(containing: transaction))
} else {
return .jws(jwsRepresentation)
}
}

static func logPurchase(product: StoreProduct, package: Package?, offer: PromotionalOffer.SignedData? = nil) {
let string: PurchaseStrings = {
switch (package, offer) {
Expand Down
Loading

0 comments on commit 32c63cd

Please sign in to comment.