Skip to content

Commit

Permalink
PurchasesOrchestrator: return early if receipt has no transactions …
Browse files Browse the repository at this point in the history
…when checking for promo offers (#3123)

Avoids an extra request to the /offers endpoint in the common case where
receipt has no trasactions.
  • Loading branch information
MarkVillacampa authored Aug 30, 2023
1 parent cdcd1a0 commit 59034d0
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 18 deletions.
45 changes: 27 additions & 18 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ final class PurchasesOrchestrator {
@available(iOS 12.2, macOS 10.14.4, watchOS 6.2, macCatalyst 13.0, tvOS 12.2, *)
func promotionalOffer(forProductDiscount productDiscount: StoreProductDiscountType,
product: StoreProductType,
completion: @escaping (Result<PromotionalOffer, PurchasesError>) -> Void) {
completion: @escaping @Sendable (Result<PromotionalOffer, PurchasesError>) -> Void) {
guard let discountIdentifier = productDiscount.offerIdentifier else {
completion(.failure(ErrorUtils.productDiscountMissingIdentifierError()))
return
Expand All @@ -249,24 +249,33 @@ final class PurchasesOrchestrator {
return
}

self.backend.offerings.post(offerIdForSigning: discountIdentifier,
productIdentifier: product.productIdentifier,
subscriptionGroup: subscriptionGroupIdentifier,
receiptData: receiptData,
appUserID: self.appUserID) { result in
let result: Result<PromotionalOffer, PurchasesError> = result
.map { data in
let signedData = PromotionalOffer.SignedData(identifier: discountIdentifier,
keyIdentifier: data.keyIdentifier,
nonce: data.nonce,
signature: data.signature,
timestamp: data.timestamp)

return .init(discount: productDiscount, signedData: signedData)
}
.mapError { $0.asPurchasesError }
self.operationDispatcher.dispatchOnWorkerThread {
if !self.receiptParser.receiptHasTransactions(receiptData: receiptData) {
// Promotional offers require existing purchases.
// Fail early if receipt has no transactions.
completion(.failure(ErrorUtils.ineligibleError()))
return
}

completion(result)
self.backend.offerings.post(offerIdForSigning: discountIdentifier,
productIdentifier: product.productIdentifier,
subscriptionGroup: subscriptionGroupIdentifier,
receiptData: receiptData,
appUserID: self.appUserID) { result in
let result: Result<PromotionalOffer, PurchasesError> = result
.map { data in
let signedData = PromotionalOffer.SignedData(identifier: discountIdentifier,
keyIdentifier: data.keyIdentifier,
nonce: data.nonce,
signature: data.signature,
timestamp: data.timestamp)

return .init(discount: productDiscount, signedData: signedData)
}
.mapError { $0.asPurchasesError }

completion(result)
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,14 @@ class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests {
func testGetPromotionalOfferWithNoPurchasesReturnsIneligible() async throws {
let product = try await self.monthlyPackage.storeProduct
let discount = try XCTUnwrap(product.discounts.onlyElement)
self.logger.clearMessages()

do {
_ = try await self.purchases.promotionalOffer(forProductDiscount: discount, product: product)
} catch {
expect(error).to(matchError(ErrorCode.ineligibleError))
}
self.logger.verifyMessageWasNotLogged("API request started")
}

func testUserHasNoEligibleOffersByDefault() async throws {
Expand Down
55 changes: 55 additions & 0 deletions Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase {
func testGetSK1PromotionalOffer() async throws {
customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo
offerings.stubbedPostOfferCompletionResult = .success(("signature", "identifier", UUID(), 12345))
self.receiptParser.stubbedReceiptHasTransactionsResult = true

let product = try await fetchSk1Product()

Expand Down Expand Up @@ -426,6 +427,59 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase {
expect(self.offerings.invokedPostOffer) == false
}

func testGetPromotionalOfferFailsWithIneligibleIfReceiptHasNoTransactions() async throws {
self.receiptParser.stubbedReceiptHasTransactionsResult = false

let product = try await self.fetchSk1Product()
let storeProductDiscount = MockStoreProductDiscount(offerIdentifier: "offerid1",
currencyCode: product.priceLocale.currencyCode,
price: 11.1,
localizedPriceString: "$11.10",
paymentMode: .payAsYouGo,
subscriptionPeriod: .init(value: 1, unit: .month),
numberOfPeriods: 2,
type: .promotional)

do {
_ = try await Async.call { completion in
self.orchestrator.promotionalOffer(forProductDiscount: storeProductDiscount,
product: StoreProduct(sk1Product: product),
completion: completion)
}
} catch {
expect(error).to(matchError(ErrorCode.ineligibleError))
}

expect(self.offerings.invokedPostOffer) == false
}

func testGetPromotionalOfferWorksWhenReceiptHasTransactions() async throws {
customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo
offerings.stubbedPostOfferCompletionResult = .success(("signature", "identifier", UUID(), 12345))
self.receiptParser.stubbedReceiptHasTransactionsResult = true

let product = try await self.fetchSk1Product()
let storeProductDiscount = MockStoreProductDiscount(offerIdentifier: "offerid1",
currencyCode: product.priceLocale.currencyCode,
price: 11.1,
localizedPriceString: "$11.10",
paymentMode: .payAsYouGo,
subscriptionPeriod: .init(value: 1, unit: .month),
numberOfPeriods: 2,
type: .promotional)

let result = try await Async.call { completion in
orchestrator.promotionalOffer(forProductDiscount: storeProductDiscount,
product: StoreProduct(sk1Product: product),
completion: completion)
}

expect(result.signedData.identifier) == storeProductDiscount.offerIdentifier

expect(self.offerings.invokedPostOfferCount) == 1
expect(self.offerings.invokedPostOfferParameters?.offerIdentifier) == storeProductDiscount.offerIdentifier
}

func testGetSK1PromotionalOfferFailsWithIneligibleDiscount() async throws {
self.customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo
self.offerings.stubbedPostOfferCompletionResult = .failure(
Expand Down Expand Up @@ -1023,6 +1077,7 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase {
customerInfoManager.stubbedCachedCustomerInfoResult = mockCustomerInfo
backend.stubbedPostReceiptResult = .success(mockCustomerInfo)
offerings.stubbedPostOfferCompletionResult = .success(("signature", "identifier", UUID(), 12345))
self.receiptParser.stubbedReceiptHasTransactionsResult = true

let storeProduct = try await self.fetchSk2StoreProduct()

Expand Down

0 comments on commit 59034d0

Please sign in to comment.