Skip to content

StoreKit 2: Optionally send JWS tokens instead of receipts to the backend#3227

Merged
MarkVillacampa merged 84 commits into
mainfrom
poc-sk2-jwt
Oct 16, 2023
Merged

StoreKit 2: Optionally send JWS tokens instead of receipts to the backend#3227
MarkVillacampa merged 84 commits into
mainfrom
poc-sk2-jwt

Conversation

@MarkVillacampa

@MarkVillacampa MarkVillacampa commented Sep 19, 2023

Copy link
Copy Markdown
Member
  • Added a new option usesStoreKit2JWS under DangerousSettings. If enabled, the SDK will send a JWS token instead of a receipt to the applicable backend endpoints.
  • The option must be used in conjunction with usesStoreKit2IfAvailable configuration option.

ToDo

  • Send JWS token when making a purchase in SK2 mode
  • Send JWS token when calculating promo offer eligibility
  • Update syncTransactions to send the latest transaction JWS.
  • Add tests

Comment thread Sources/Misc/DangerousSettings.swift Outdated
* Must be used in conjunction with the `usesStoreKit2IfAvailable configuration` option.
*/
@objc public let usesStoreKit2JWS: Bool

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually, we could merge this option with usesStoreKit2IfAvailable outright.

Comment thread Sources/Networking/CustomerAPI.swift Outdated
var receiptData = receiptData
if !self.backendConfig.systemInfo.dangerousSettings.usesStoreKit2JWS {
receiptData = receiptData.asFetchToken.asData
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MarkVillacampa MarkVillacampa added the pr:feat A new feature label Sep 19, 2023
Comment thread Sources/Misc/DangerousSettings.swift Outdated
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for us but we need to be able to use it in PurchaseTester so internal would not work I think

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not put this in DangerousSettings then, let's move it to InternalDangerousSettingsType

@MarkVillacampa MarkVillacampa added enhancement and removed pr:feat A new feature labels Sep 19, 2023
}

var fetchToken: String { return self.receiptData.asFetchToken }
var fetchToken: String { return String(data: self.receiptData, encoding: .utf8) ?? "" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/Misc/DangerousSettings.swift Outdated
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not put this in DangerousSettings then, let's move it to InternalDangerousSettingsType

@NachoSoto NachoSoto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread Sources/Networking/OfferingsAPI.swift Outdated
Comment on lines +80 to +83
var receiptData = receiptData
if !self.backendConfig.systemInfo.dangerousSettings.usesStoreKit2JWS {
receiptData = receiptData.asFetchToken.asData
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
    }
  }
}

@NachoSoto NachoSoto Sep 19, 2023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
    }
  }
}

Comment thread Sources/Misc/DangerousSettings.swift Outdated
internalSettings: InternalDangerousSettingsType) {
self.autoSyncPurchases = autoSyncPurchases
self.internalSettings = internalSettings
self.usesStoreKit2JWS = usesStoreKit2JWS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's test to make sure this works with no transactions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use first (without closure). Also, you can use keypath in the compactMap and StoreTransaction.init in the map.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to change this 😇

import StoreKit

final class MockPurchasedProductsFetcher: PurchasedProductsFetcherType {
class MockPurchasedProductsFetcher: PurchasedProductsFetcherType {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to change this?

Comment on lines +1168 to +1308
// Promotional offers require existing purchases.
// Fail early if there are no transactions
completion(.failure(ErrorUtils.ineligibleError()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻
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)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this to StoreKitStrings?

@codecov

codecov Bot commented Oct 9, 2023

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 97.28682% with 7 lines in your changes missing coverage. Please review.

Project coverage is 85.88%. Comparing base (1ad14d8) to head (f6875a4).
Report is 516 commits behind head on main.

Files with missing lines Patch % Lines
...tworking/Operations/PostReceiptDataOperation.swift 84.37% 5 Missing ⚠️
Sources/Misc/Deprecations.swift 0.00% 1 Missing ⚠️
Sources/Purchasing/Purchases/Purchases.swift 75.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

let transaction = await self.transactionFetcher.fetchLastVerifiedAutoRenewableTransaction()
guard let transaction = transaction, let jwsRepresentation = transaction.jsonRepresentation else {
self.operationDispatcher.dispatchOnMainThread {
completion?(.failure(ErrorUtils.transactionNotFoundError()))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we fail if syncPurchases is called when user has no transactions?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NachoSoto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 @@
//

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move this file to StoreKitAbstractions?


import Foundation

struct EncodedAppleReceipt: Equatable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though it's internal, a docstring might help devs in the future:

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: our style guide requires lines at the beginning and the end of every type, extension, etc.

import Foundation

struct EncodedAppleReceipt: Equatable {
enum ReceiptType {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This becomes EncodedAppleReceipt.ReceiptType which is a bit redundant, I'd change this to just

Suggested change
enum ReceiptType {
enum `Type` {

Comment thread Sources/Networking/Backend.swift Outdated
}

func post(receiptData: Data,
func post(receiptData: EncodedAppleReceipt,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we verify the result too?

darkMode: true,
date: .init(timeIntervalSince1970: 1694029328)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed :P

expect(result) == nil
}

func testLastVerifiedTransactionDoesNotIncludeFinishedConsumableTransaction() async throws {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome 👍🏻

Comment on lines +25 to +27
let dangerousSettings = DangerousSettings(
autoSyncPurchases: true, customEntitlementComputation: customEntitlementsComputation,
internalSettings: DangerousSettings.Internal(usesStoreKit2JWS: usesStoreKit2JWS))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting here is weird, maybe move all the parameters to their own line?

Comment on lines +78 to +79
let discountData = EncodedAppleReceipt(type: .receipt,
data: "an awesome discount".data(using: String.Encoding.utf8)!)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe create an extension in this file to simplify this:

private extension EncodedAppleReceipt {
  init(receiptString: String) {
    self.init(type: .receipt, data: receiptString.asData)
  }
}

@MarkVillacampa MarkVillacampa added pr:feat A new feature and removed refactor labels Oct 13, 2023
Comment on lines +18 to +19
struct EncodedAppleReceipt: Equatable {
enum `Type`: Equatable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated below, can you extract it?

let transaction = StoreTransaction(
  sk2Transaction: verifiedTransaction,
    jwsRepresentation: transactionResult.jwsRepresentation
)

if fromTransactionUpdate, let delegate = self.delegate {...}

return transaction

Comment on lines +93 to +98
if systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS,
let jwsRepresentation = transaction.jwsRepresentation {
self.fetchProductsAndPostReceipt(
transaction: transaction,
data: data,
receipt: EncodedAppleReceipt(jws: jwsRepresentation),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

}
}

var verifiedStoreTransaction: StoreTransaction? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't used anywhere, make it fileprivate?


extension BaseBackendIntegrationTests: InternalDangerousSettingsType {

var usesStoreKit2JWS: Bool { false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make sure we write tests with this on before we use it. This is fine for now 👍🏻

Comment thread Sources/Networking/Operations/PostReceiptDataOperation.swift
Comment on lines +195 to +197
case .jws:
self.log(Strings.receipt.posting_jws(
self.postData.receipt.serialized(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified:

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed

let cacheKey =
"""
\(configuration.appUserID)-\(postData.isRestore)-\(postData.receiptData.hashString)
\(configuration.appUserID)-\(postData.isRestore)-\(postData.receipt.serialized().hashValue)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
    }
  }

}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as internal extension 👍

Comment thread Sources/Misc/DangerousSettings.swift Outdated
@MarkVillacampa MarkVillacampa self-assigned this Oct 16, 2023

internal extension EncodedAppleReceipt {

var hash: String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only relevant in the context of creating a hash for operations, so I'd make it private put it there.

@MarkVillacampa MarkVillacampa changed the title [WIP] StoreKit 2: Optionally send JWS tokens instead of receipts to the backend StoreKit 2: Optionally send JWS tokens instead of receipts to the backend Oct 16, 2023

func syncPurchases(completion: (@Sendable (Result<CustomerInfo, PurchasesError>) -> Void)? = nil) {
self.syncPurchases(receiptRefreshPolicy: .never,
self.syncPurchases(receiptRefreshPolicy: .always,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Uh oh, this is a breaking change. Did you mean to change this?

subscriberAttributes: nil,
completion: completion)
if systemInfo.dangerousSettings.internalSettings.usesStoreKit2JWS,
let jwsRepresentation = transaction.jwsRepresentation {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that this has no tests either in TransactionPosterTests.

Comment on lines +63 to +65
var transaction: StoreTransaction?
if let mockTransaction = self.mockTransaction.value {
transaction = StoreTransaction(sk2Transaction: mockTransaction, jwsRepresentation: "")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified as:

Suggested change
var transaction: StoreTransaction?
if let mockTransaction = self.mockTransaction.value {
transaction = StoreTransaction(sk2Transaction: mockTransaction, jwsRepresentation: "")
}
let transaction: StoreTransaction = self.mockTransaction.map {
StoreTransaction(sk2Transaction: $0, jwsRepresentation: "")
}

@NachoSoto NachoSoto removed enhancement pr:feat A new feature labels Oct 16, 2023
MarkVillacampa and others added 22 commits October 16, 2023 20:58
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>
@NachoSoto

Copy link
Copy Markdown
Contributor

:shipit:

@MarkVillacampa MarkVillacampa merged commit 235a7dc into main Oct 16, 2023
@MarkVillacampa MarkVillacampa deleted the poc-sk2-jwt branch October 16, 2023 20:45
NachoSoto pushed a commit that referenced this pull request Oct 18, 2023
**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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants