StoreKit 2: Optionally send JWS tokens instead of receipts to the backend#3227
Conversation
| * Must be used in conjunction with the `usesStoreKit2IfAvailable configuration` option. | ||
| */ | ||
| @objc public let usesStoreKit2JWS: Bool | ||
|
|
There was a problem hiding this comment.
Eventually, we could merge this option with usesStoreKit2IfAvailable outright.
| var receiptData = receiptData | ||
| if !self.backendConfig.systemInfo.dangerousSettings.usesStoreKit2JWS { | ||
| receiptData = receiptData.asFetchToken.asData | ||
| } |
There was a problem hiding this comment.
We send the receipt base64-encoded, while the JWS token is sent as it is. I don't particularly like the placement of this piece of code, ideally we would be doing the encoding on the call site, but changing that broke a lot of receipt tests that were expecting unencoded receipts. Open to suggestions on how to handle this better.
| product: StoreProductType, | ||
| subscriptionGroupIdentifier: String, | ||
| completion: @escaping @Sendable (Result<PromotionalOffer, PurchasesError>) -> Void) { | ||
| self.transactionFetcher.fetchLastVerifiedTransaction { transaction in |
There was a problem hiding this comment.
We need at least one JWS token in the backend in order to sign the offer, plus we get to fail early if user has not made any purchase.
| * Controls whether StoreKit 2 JWS tokens are sent to RevenueCat instead of StoreKit 1 receipts. | ||
| * Must be used in conjunction with the `usesStoreKit2IfAvailable configuration` option. | ||
| */ | ||
| @objc public let usesStoreKit2JWS: Bool |
There was a problem hiding this comment.
Since this is just a "dangerous" setting, even though technically it's a public API, I think we can get away with making this not a minor (so not using feat).
There was a problem hiding this comment.
Wondering what you have in mind for this: is this just for us to test, or do we expect others to be able to use this?
I'm asking because we could make this internal only.
There was a problem hiding this comment.
Just for us but we need to be able to use it in PurchaseTester so internal would not work I think
There was a problem hiding this comment.
I would not put this in DangerousSettings then, let's move it to InternalDangerousSettingsType
| } | ||
|
|
||
| var fetchToken: String { return self.receiptData.asFetchToken } | ||
| var fetchToken: String { return String(data: self.receiptData, encoding: .utf8) ?? "" } |
There was a problem hiding this comment.
It's weird that we're converting it from string to data and now back to string. Let's change the parameter in these 2 operations to String to avoid that.
| * Controls whether StoreKit 2 JWS tokens are sent to RevenueCat instead of StoreKit 1 receipts. | ||
| * Must be used in conjunction with the `usesStoreKit2IfAvailable configuration` option. | ||
| */ | ||
| @objc public let usesStoreKit2JWS: Bool |
There was a problem hiding this comment.
I would not put this in DangerousSettings then, let's move it to InternalDangerousSettingsType
NachoSoto
left a comment
There was a problem hiding this comment.
Great job here! Just some suggestions on how to avoid duplicating some code and things to test. Let me know if I can help with any of these!
| var receiptData = receiptData | ||
| if !self.backendConfig.systemInfo.dangerousSettings.usesStoreKit2JWS { | ||
| receiptData = receiptData.asFetchToken.asData | ||
| } |
There was a problem hiding this comment.
Let's move this to a method. I'm thinking maybe AppleReceipt+Serialization.swift:
extension AppleReceipt {
static func serialized(receiptData: Data, forJWS: Bool) -> String {
if forJWS {
return String(data: receiptData, encoding: .utf8) ?? ""
} else {
return receiptData.asFetchToken
}
}
}There was a problem hiding this comment.
Another idea:
struct EncodedAppleReceipt {
let data: Data
}
extension EncodedAppleReceipt {
func serialized(forJWS: Bool) -> String {
if forJWS {
return String(data: self.data, encoding: .utf8) ?? ""
} else {
return self.data.asFetchToken
}
}
}| internalSettings: InternalDangerousSettingsType) { | ||
| self.autoSyncPurchases = autoSyncPurchases | ||
| self.internalSettings = internalSettings | ||
| self.usesStoreKit2JWS = usesStoreKit2JWS |
There was a problem hiding this comment.
From our conversation:
#if DEBUG
@testable import RevenueCat
#else
import RevenueCat
#endif#if DEBUG
.with(dangerousSettings: .init(internalSettings: DangerousSettings.Internal(useJWT: useStoreKit2)))
#endif| } | ||
|
|
||
| @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) | ||
| func fetchLastVerifiedTransaction(completion: @escaping (StoreTransaction?) -> Void) { |
There was a problem hiding this comment.
I'd change to async for consistency with the rest, and use the completion block outside.
| completion(await StoreKit.Transaction.all | ||
| .compactMap { $0.verifiedTransaction } | ||
| .map { StoreTransaction(sk2Transaction: $0) } | ||
| .first { _ in true }) |
There was a problem hiding this comment.
Let's test to make sure this works with no transactions.
There was a problem hiding this comment.
Just use first (without closure). Also, you can use keypath in the compactMap and StoreTransaction.init in the map.
There was a problem hiding this comment.
This is an AsyncStream not a regular collection.
The separate map and compact map are fine, separate modification helps with clarity.
| repositoryURL = "https://github.com/RevenueCat/purchases-ios"; | ||
| requirement = { | ||
| branch = main; | ||
| branch = "poc-sk2-jwt"; |
There was a problem hiding this comment.
Reminder to change this 😇
| import StoreKit | ||
|
|
||
| final class MockPurchasedProductsFetcher: PurchasedProductsFetcherType { | ||
| class MockPurchasedProductsFetcher: PurchasedProductsFetcherType { |
There was a problem hiding this comment.
Did you mean to change this?
| // Promotional offers require existing purchases. | ||
| // Fail early if there are no transactions | ||
| completion(.failure(ErrorUtils.ineligibleError())) |
There was a problem hiding this comment.
👍🏻
Let's add tests for this too.
The good thing is that our current integration tests will cover this as well, but I'd cover it in PurchasesOrchestratorTests too.
| completion: completion) | ||
| if systemInfo.dangerousSettings.usesStoreKit2JWS { | ||
| guard let jsonRepresentation = transaction.jsonRepresentation else { | ||
| Logger.error("Could not fetch JWS token for transaction with ID \(transaction.transactionIdentifier)") |
There was a problem hiding this comment.
Can you move this to StoreKitStrings?
4576941 to
ddcdff3
Compare
e41d4b5 to
2d5a686
Compare
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3227 +/- ##
==========================================
+ Coverage 85.61% 85.88% +0.26%
==========================================
Files 236 237 +1
Lines 16891 17061 +170
==========================================
+ Hits 14462 14652 +190
+ Misses 2429 2409 -20 ☔ View full report in Codecov by Sentry. |
| let transaction = await self.transactionFetcher.fetchLastVerifiedAutoRenewableTransaction() | ||
| guard let transaction = transaction, let jwsRepresentation = transaction.jsonRepresentation else { | ||
| self.operationDispatcher.dispatchOnMainThread { | ||
| completion?(.failure(ErrorUtils.transactionNotFoundError())) |
There was a problem hiding this comment.
Should we fail if syncPurchases is called when user has no transactions?
There was a problem hiding this comment.
I would match the existing logic, so no:
let hasTransactions = self.transactionsManager.customerHasTransactions(receiptData: receiptData)
let cachedCustomerInfo = self.customerInfoManager.cachedCustomerInfo(appUserID: currentAppUserID)
if !hasTransactions, let customerInfo = cachedCustomerInfo, customerInfo.originalPurchaseDate != nil {
if let completion = completion {
self.operationDispatcher.dispatchOnMainThread {
completion(.success(customerInfo))
}
}
return
}That way we don't need a new ErrorCode.
There was a problem hiding this comment.
The scenario here is: if a user has no previous transactions, we cannot fetch any token, and hence we cannot post it to /receipt because it would return a 400 error. Instead, I fetch customer info and return it.
NachoSoto
left a comment
There was a problem hiding this comment.
Awesome work 🙌🏻
Sorry for the long code review, mostly a lot of small things. The main blocker is some duplicated code and logic improvements, but I think it's mostly ready!
| @@ -0,0 +1,33 @@ | |||
| // | |||
There was a problem hiding this comment.
Maybe move this file to StoreKitAbstractions?
|
|
||
| import Foundation | ||
|
|
||
| struct EncodedAppleReceipt: Equatable { |
There was a problem hiding this comment.
Even though it's internal, a docstring might help devs in the future:
| struct EncodedAppleReceipt: Equatable { | |
| /// Represents an `AppleReceipt` that's been encoded | |
| /// in a suitable representation for the RevenueCat backend. | |
| struct EncodedAppleReceipt: Equatable { |
| case jwt, receipt | ||
| } | ||
| let type: ReceiptType | ||
| let data: Data |
There was a problem hiding this comment.
Nit: our style guide requires lines at the beginning and the end of every type, extension, etc.
| import Foundation | ||
|
|
||
| struct EncodedAppleReceipt: Equatable { | ||
| enum ReceiptType { |
There was a problem hiding this comment.
This becomes EncodedAppleReceipt.ReceiptType which is a bit redundant, I'd change this to just
| enum ReceiptType { | |
| enum `Type` { |
| } | ||
|
|
||
| func post(receiptData: Data, | ||
| func post(receiptData: EncodedAppleReceipt, |
There was a problem hiding this comment.
Maybe rename these to post(receipt: EncodedAppleReceipt) now that it's not a Data?
|
|
||
| _ = await withCheckedContinuation { continuation in | ||
| self.orchestrator.syncPurchases { result in | ||
| continuation.resume(returning: result.value) |
There was a problem hiding this comment.
Should we verify the result too?
| darkMode: true, | ||
| date: .init(timeIntervalSince1970: 1694029328) | ||
| ) | ||
|
|
| expect(result) == nil | ||
| } | ||
|
|
||
| func testLastVerifiedTransactionDoesNotIncludeFinishedConsumableTransaction() async throws { |
| let dangerousSettings = DangerousSettings( | ||
| autoSyncPurchases: true, customEntitlementComputation: customEntitlementsComputation, | ||
| internalSettings: DangerousSettings.Internal(usesStoreKit2JWS: usesStoreKit2JWS)) |
There was a problem hiding this comment.
Formatting here is weird, maybe move all the parameters to their own line?
| let discountData = EncodedAppleReceipt(type: .receipt, | ||
| data: "an awesome discount".data(using: String.Encoding.utf8)!) |
There was a problem hiding this comment.
Nit: maybe create an extension in this file to simplify this:
private extension EncodedAppleReceipt {
init(receiptString: String) {
self.init(type: .receipt, data: receiptString.asData)
}
}
| struct EncodedAppleReceipt: Equatable { | ||
| enum `Type`: Equatable { |
There was a problem hiding this comment.
This new definition is great 👍🏻
But now the extra type is unnecessary, how about defining it like this:
enum EncodedAppleReceipt: Equatable {
case jws(String)
case receipt(Data)
}Then you don't even need the redundant constructors.
| try await delegate.storeKit2TransactionListener( | ||
| self, | ||
| updatedTransaction: StoreTransaction(sk2Transaction: verifiedTransaction) | ||
| updatedTransaction: StoreTransaction(sk2Transaction: verifiedTransaction, |
There was a problem hiding this comment.
This is duplicated below, can you extract it?
let transaction = StoreTransaction(
sk2Transaction: verifiedTransaction,
jwsRepresentation: transactionResult.jwsRepresentation
)
if fromTransactionUpdate, let delegate = self.delegate {...}
return transaction| if systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS, | ||
| let jwsRepresentation = transaction.jwsRepresentation { | ||
| self.fetchProductsAndPostReceipt( | ||
| transaction: transaction, | ||
| data: data, | ||
| receipt: EncodedAppleReceipt(jws: jwsRepresentation), |
| } | ||
| } | ||
|
|
||
| var verifiedStoreTransaction: StoreTransaction? { |
There was a problem hiding this comment.
This isn't used anywhere, make it fileprivate?
|
|
||
| extension BaseBackendIntegrationTests: InternalDangerousSettingsType { | ||
|
|
||
| var usesStoreKit2JWS: Bool { false } |
There was a problem hiding this comment.
Let's make sure we write tests with this on before we use it. This is fine for now 👍🏻
| case .jws: | ||
| self.log(Strings.receipt.posting_jws( | ||
| self.postData.receipt.serialized(), |
There was a problem hiding this comment.
This can be simplified:
| case .jws: | |
| self.log(Strings.receipt.posting_jws( | |
| self.postData.receipt.serialized(), | |
| case let .jws(content): | |
| self.log(Strings.receipt.posting_jws( | |
| content, |
| productIdentifier: purchase.productId, | ||
| purchase: purchase.purchaseDate, | ||
| expiration: purchase.expiresDate | ||
| return |
| let cacheKey = | ||
| """ | ||
| \(configuration.appUserID)-\(postData.isRestore)-\(postData.receiptData.hashString) | ||
| \(configuration.appUserID)-\(postData.isRestore)-\(postData.receipt.serialized().hashValue) |
There was a problem hiding this comment.
Hmm this was using receiptData.hashString to prevent this cache key from being a huge string.
I'd recommend doing this instead now;
private extension EncodedAppleReceipt {
var hash: String {
switch self {
case let .jws(content): return content.asData.hashString
case let .receipt(data): return data.hashString
}
}
}
There was a problem hiding this comment.
Added as internal extension 👍
|
|
||
| internal extension EncodedAppleReceipt { | ||
|
|
||
| var hash: String { |
There was a problem hiding this comment.
This is only relevant in the context of creating a hash for operations, so I'd make it private put it there.
StoreKit 2: Optionally send JWS tokens instead of receipts to the backendStoreKit 2: Optionally send JWS tokens instead of receipts to the backend
b36c37b to
b69edae
Compare
|
|
||
| func syncPurchases(completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)? = nil) { | ||
| self.syncPurchases(receiptRefreshPolicy: .never, | ||
| self.syncPurchases(receiptRefreshPolicy: .always, |
There was a problem hiding this comment.
| subscriberAttributes: nil, | ||
| completion: completion) | ||
| if systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS, | ||
| let jwsRepresentation = transaction.jwsRepresentation { |
There was a problem hiding this comment.
I just realized that this has no tests either in TransactionPosterTests.
| var transaction: StoreTransaction? | ||
| if let mockTransaction = self.mockTransaction.value { | ||
| transaction = StoreTransaction(sk2Transaction: mockTransaction, jwsRepresentation: "") | ||
| } |
There was a problem hiding this comment.
This can be simplified as:
| var transaction: StoreTransaction? | |
| if let mockTransaction = self.mockTransaction.value { | |
| transaction = StoreTransaction(sk2Transaction: mockTransaction, jwsRepresentation: "") | |
| } | |
| let transaction: StoreTransaction = self.mockTransaction.map { | |
| StoreTransaction(sk2Transaction: $0, jwsRepresentation: "") | |
| } |
Requested by @MarkVillacampa for [poc-sk2-jwt](https://github.com/RevenueCat/purchases-ios/tree/poc-sk2-jwt) Co-authored-by: Distiller <distiller@static.38.39.183.118.cyberlynk.net>
Requested by @MarkVillacampa for [poc-sk2-jwt](https://github.com/RevenueCat/purchases-ios/tree/poc-sk2-jwt) Co-authored-by: Distiller <distiller@static.198.206.135.125.macminivault.com>
Requested by @MarkVillacampa for [poc-sk2-jwt](https://github.com/RevenueCat/purchases-ios/tree/poc-sk2-jwt) Co-authored-by: Distiller <distiller@static.38.23.39.112.cyberlynk.net>
Requested by @MarkVillacampa for [poc-sk2-jwt](https://github.com/RevenueCat/purchases-ios/tree/poc-sk2-jwt) Co-authored-by: Distiller <distiller@static.38.39.183.58.cyberlynk.net>
Requested by @MarkVillacampa for [poc-sk2-jwt](https://github.com/RevenueCat/purchases-ios/tree/poc-sk2-jwt) Co-authored-by: Distiller <distiller@static.38.39.185.87.cyberlynk.net>
…t no JWS token is present
ab60063 to
f6875a4
Compare
|
|
**This is an automatic release.** ### Bugfixes * `PaywallEventStore`: also remove legacy `revenuecat` documents directory (#3317) via NachoSoto (@NachoSoto) ### Other Changes * `CI`: run all iOS 17 tests (#3312) via NachoSoto (@NachoSoto) * `StoreKit 2`: Optionally send JWS tokens instead of receipts to the backend (#3227) via Mark Villacampa (@MarkVillacampa) * `CircleCI`: update simulators for Xcode 15.0.1 (#3311) via NachoSoto (@NachoSoto) * `StoreKit 1`: improved debug log for `finishTransactions` invoked outside the SDK (#3300) via NachoSoto (@NachoSoto) * `Debug View`: display receipt status (#3303) via NachoSoto (@NachoSoto)
usesStoreKit2JWSunderDangerousSettings. If enabled, the SDK will send a JWS token instead of a receipt to the applicable backend endpoints.usesStoreKit2IfAvailableconfiguration option.ToDo
syncTransactionsto send the latest transaction JWS.