diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 8c0049fba8..ff2c3f5270 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -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) -> Void) { + completion: @escaping @Sendable (Result) -> Void) { guard let discountIdentifier = productDiscount.offerIdentifier else { completion(.failure(ErrorUtils.productDiscountMissingIdentifierError())) return @@ -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 = 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 = 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) + } } } } diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index b48b4a711d..405c1e7eba 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -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 { diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index e34744e64f..8635c8dd1b 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -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() @@ -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( @@ -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()