From 442a46c4a95898ac688175ec855e76d8cdcca402 Mon Sep 17 00:00:00 2001 From: Surik Date: Wed, 24 Sep 2025 14:39:53 +0300 Subject: [PATCH] Added a part of purchase result logic --- Podfile.lock | 10 +- Qonversion.xcodeproj/project.pbxproj | 12 ++ Sources/Qonversion/Public/QONPurchaseResult.h | 48 +++++ Sources/Qonversion/Public/QONPurchaseResult.m | 54 +++++ Sources/Qonversion/Public/Qonversion.h | 12 ++ Sources/Qonversion/Public/Qonversion.m | 6 + .../QNProductCenterManager.h | 10 +- .../QNProductCenterManager.m | 187 ++++++++++++++---- .../Protected/QONPurchaseResult+Protected.h | 60 ++++++ 9 files changed, 351 insertions(+), 48 deletions(-) create mode 100644 Sources/Qonversion/Public/QONPurchaseResult.h create mode 100644 Sources/Qonversion/Public/QONPurchaseResult.m create mode 100644 Sources/Qonversion/Qonversion/Models/Protected/QONPurchaseResult+Protected.h diff --git a/Podfile.lock b/Podfile.lock index f9240db5..e31b6617 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -65,9 +65,9 @@ PODS: - nanopb/encode (2.30908.0) - OCMock (3.9.4) - PromisesObjC (2.4.0) - - Qonversion (5.13.4): - - Qonversion/Main (= 5.13.4) - - Qonversion/Main (5.13.4) + - Qonversion (5.14.0): + - Qonversion/Main (= 5.14.0) + - Qonversion/Main (5.14.0) DEPENDENCIES: - Firebase/Auth (= 8.9.0) @@ -109,8 +109,8 @@ SPEC CHECKSUMS: nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 OCMock: 589f2c84dacb1f5aaf6e4cec1f292551fe748e74 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - Qonversion: 87bf82a98ce120fe6d90664c46b5b27a89efd65c + Qonversion: 7a392c3e17ac2442fcda5e8f6c57fd7c429f7cbb -PODFILE CHECKSUM: 44d29dbe325cd066d8cab5db21496f3b2a959485 +PODFILE CHECKSUM: b64bc6917b5b209ae92a56b5699e8790ade97930 COCOAPODS: 1.16.2 diff --git a/Qonversion.xcodeproj/project.pbxproj b/Qonversion.xcodeproj/project.pbxproj index 8a87397b..7a64e089 100644 --- a/Qonversion.xcodeproj/project.pbxproj +++ b/Qonversion.xcodeproj/project.pbxproj @@ -60,6 +60,9 @@ 700636072C11CEF700BB9F9C /* QONFallbackObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 700636052C11CEF700BB9F9C /* QONFallbackObject.m */; }; 700636142C12125900BB9F9C /* NSError+Sugare.h in Headers */ = {isa = PBXBuildFile; fileRef = 700636122C12125900BB9F9C /* NSError+Sugare.h */; }; 700636152C12125900BB9F9C /* NSError+Sugare.m in Sources */ = {isa = PBXBuildFile; fileRef = 700636132C12125900BB9F9C /* NSError+Sugare.m */; }; + 700B9D4D2E742CBB0031B056 /* QONPurchaseResult.m in Sources */ = {isa = PBXBuildFile; fileRef = 700B9D4C2E742CBB0031B056 /* QONPurchaseResult.m */; }; + 700B9D4E2E742CBB0031B056 /* QONPurchaseResult.h in Headers */ = {isa = PBXBuildFile; fileRef = 700B9D4B2E742CBB0031B056 /* QONPurchaseResult.h */; }; + 700B9D502E742E5D0031B056 /* QONPurchaseResult+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 700B9D4F2E742E5D0031B056 /* QONPurchaseResult+Protected.h */; }; 700EC173291277130032E205 /* QONExperimentGroup+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 700EC171291277130032E205 /* QONExperimentGroup+Protected.h */; }; 701922732B10981200724926 /* QONSubscriptionPeriod.h in Headers */ = {isa = PBXBuildFile; fileRef = 701922712B10981200724926 /* QONSubscriptionPeriod.h */; settings = {ATTRIBUTES = (Public, ); }; }; 701922742B10981200724926 /* QONSubscriptionPeriod.m in Sources */ = {isa = PBXBuildFile; fileRef = 701922722B10981200724926 /* QONSubscriptionPeriod.m */; }; @@ -408,6 +411,9 @@ 700636052C11CEF700BB9F9C /* QONFallbackObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONFallbackObject.m; sourceTree = ""; }; 700636122C12125900BB9F9C /* NSError+Sugare.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSError+Sugare.h"; sourceTree = ""; }; 700636132C12125900BB9F9C /* NSError+Sugare.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSError+Sugare.m"; sourceTree = ""; }; + 700B9D4B2E742CBB0031B056 /* QONPurchaseResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QONPurchaseResult.h; sourceTree = ""; }; + 700B9D4C2E742CBB0031B056 /* QONPurchaseResult.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONPurchaseResult.m; sourceTree = ""; }; + 700B9D4F2E742E5D0031B056 /* QONPurchaseResult+Protected.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "QONPurchaseResult+Protected.h"; sourceTree = ""; }; 700EC171291277130032E205 /* QONExperimentGroup+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "QONExperimentGroup+Protected.h"; sourceTree = ""; }; 701922712B10981200724926 /* QONSubscriptionPeriod.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QONSubscriptionPeriod.h; sourceTree = ""; }; 701922722B10981200724926 /* QONSubscriptionPeriod.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONSubscriptionPeriod.m; sourceTree = ""; }; @@ -1125,6 +1131,8 @@ A1839793226FD80F320A246F /* QONUserProperty.h */, A1839ACEB01696C50FEBB4F4 /* QONUserProperties.m */, A1839DCD5FA33E85193A2684 /* QONUserProperties.h */, + 700B9D4B2E742CBB0031B056 /* QONPurchaseResult.h */, + 700B9D4C2E742CBB0031B056 /* QONPurchaseResult.m */, 70CB7CDB2C246DF200241FF1 /* QONPromotionalOffer.h */, 70CB7CDC2C246DF200241FF1 /* QONPromotionalOffer.m */, ); @@ -1463,6 +1471,7 @@ 8957323F26DD03A3009507A6 /* Protected */ = { isa = PBXGroup; children = ( + 700B9D4F2E742E5D0031B056 /* QONPurchaseResult+Protected.h */, 700EC171291277130032E205 /* QONExperimentGroup+Protected.h */, 7097C6BD2A38BFC800565DE4 /* QONRemoteConfig+Protected.h */, 6A121DAF2BB44D7B0073B330 /* QONRemoteConfigList+Protected.h */, @@ -1726,6 +1735,7 @@ 6A121DAE2BB446740073B330 /* QONRemoteConfigList.h in Headers */, 895732CA26DD03A3009507A6 /* QNUtils.h in Headers */, 8957328126DD03A3009507A6 /* QONEntitlement.h in Headers */, + 700B9D4E2E742CBB0031B056 /* QONPurchaseResult.h in Headers */, 8957330526DD03A3009507A6 /* QNIdentityServiceInterface.h in Headers */, 895732FE26DD03A3009507A6 /* QNAPIClient.h in Headers */, 702BF8B629531A68000B6C3E /* QONScreenCustomizationDelegate.h in Headers */, @@ -1753,6 +1763,7 @@ 895732D026DD03A3009507A6 /* QNUserInfo.h in Headers */, 895732B126DD03A3009507A6 /* QONAutomationsFlowAssembly.h in Headers */, 700636142C12125900BB9F9C /* NSError+Sugare.h in Headers */, + 700B9D502E742E5D0031B056 /* QONPurchaseResult+Protected.h in Headers */, 6A21BF532AB2059F005BDA7C /* QONRequest.h in Headers */, 8957329026DD03A3009507A6 /* QONEntitlementsUpdateListener.h in Headers */, 8957329326DD03A3009507A6 /* QONExperimentGroup.h in Headers */, @@ -2238,6 +2249,7 @@ 8957329526DD03A3009507A6 /* QONLaunchResult.m in Sources */, 70D05A9429C9FF3C00EA5DDF /* QONRemoteConfigService.m in Sources */, 895732B726DD03A3009507A6 /* QONAutomationsScreenProcessor.m in Sources */, + 700B9D4D2E742CBB0031B056 /* QONPurchaseResult.m in Sources */, 895732BD26DD03A3009507A6 /* QNRequestSerializer.m in Sources */, 895732F526DD03A3009507A6 /* QNAttributionManager.m in Sources */, 895732EF26DD03A3009507A6 /* QNErrorsMapper.m in Sources */, diff --git a/Sources/Qonversion/Public/QONPurchaseResult.h b/Sources/Qonversion/Public/QONPurchaseResult.h new file mode 100644 index 00000000..11c6b394 --- /dev/null +++ b/Sources/Qonversion/Public/QONPurchaseResult.h @@ -0,0 +1,48 @@ +// +// QONPurchaseResult.h +// Qonversion +// +// Created by Suren Sarkisyan on 18.12.2024. +// Copyright © 2024 Qonversion Inc. All rights reserved. +// + +#import +#import + +@class QONEntitlement; + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(Qonversion.PurchaseResult) +/** + * Result of a purchase operation containing entitlements, error, transaction and cancellation status. + */ +@interface QONPurchaseResult : NSObject + +/** + * Dictionary of entitlements that were granted as a result of the purchase. + * Key is entitlement identifier, value is QONEntitlement object. + */ +@property (nonatomic, copy, readonly, nullable) NSDictionary *entitlements; + +/** + * Error that occurred during the purchase process, if any. + */ +@property (nonatomic, strong, readonly, nullable) NSError *error; + +/** + * StoreKit transaction associated with the purchase. + * Can be nil if the purchase failed before reaching StoreKit. + */ +@property (nonatomic, strong, readonly, nullable) SKPaymentTransaction *transaction; + +/** + * Indicates whether the user canceled the purchase. + * This is different from a purchase failure - cancellation is user-initiated. + */ +@property (nonatomic, assign, readonly) BOOL isUserCanceled; + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Qonversion/Public/QONPurchaseResult.m b/Sources/Qonversion/Public/QONPurchaseResult.m new file mode 100644 index 00000000..54eb7591 --- /dev/null +++ b/Sources/Qonversion/Public/QONPurchaseResult.m @@ -0,0 +1,54 @@ +// +// QONPurchaseResult.m +// Qonversion +// +// Created by Suren Sarkisyan on 18.12.2024. +// Copyright © 2024 Qonversion Inc. All rights reserved. +// + +#import "QONPurchaseResult.h" +#import "QONPurchaseResult+Protected.h" +#import "QONEntitlement.h" + +@implementation QONPurchaseResult + +- (instancetype)initWithEntitlements:(nullable NSDictionary *)entitlements + transaction:(nullable SKPaymentTransaction *)transaction { + return [self initWithEntitlements:entitlements + error:nil + transaction:transaction + isUserCanceled:NO]; +} + +- (instancetype)initWithError:(nullable NSError *)error + isUserCanceled:(BOOL)isUserCanceled { + return [self initWithEntitlements:nil + error:error + transaction:nil + isUserCanceled:isUserCanceled]; +} + +- (instancetype)initWithError:(nullable NSError *)error + transaction:(nullable SKPaymentTransaction *)transaction + isUserCanceled:(BOOL)isUserCanceled { + return [self initWithEntitlements:nil + error:error + transaction:transaction + isUserCanceled:isUserCanceled]; +} + +- (instancetype)initWithEntitlements:(nullable NSDictionary *)entitlements + error:(nullable NSError *)error + transaction:(nullable SKPaymentTransaction *)transaction + isUserCanceled:(BOOL)isUserCanceled { + self = [super init]; + if (self) { + _entitlements = entitlements; + _error = error; + _transaction = transaction; + _isUserCanceled = isUserCanceled; + } + return self; +} + +@end diff --git a/Sources/Qonversion/Public/Qonversion.h b/Sources/Qonversion/Public/Qonversion.h index 59ff6a33..388ee684 100644 --- a/Sources/Qonversion/Public/Qonversion.h +++ b/Sources/Qonversion/Public/Qonversion.h @@ -22,6 +22,7 @@ #import "QONUserProperty.h" #import "QONSubscriptionPeriod.h" #import "QONPurchaseOptions.h" +#import "QONPurchaseResult.h" #import "QONPromotionalOffer.h" #if TARGET_OS_IOS @@ -174,6 +175,17 @@ static NSString *const QonversionApiErrorDomain = @"com.qonversion.io.api"; */ - (void)purchase:(NSString *)productID completion:(QONPurchaseCompletionHandler)completion; +// MARK: - New Purchase Method with PurchaseResult + +/** + Make a purchase and validate that through server-to-server using Qonversion's Backend + + @param product Product created in Qonversion Dash + @param options Purchase process additional options: quantity / context keys / etc. + @param completion Completion block that includes QONPurchaseResult with entitlements, error, transaction and cancellation status + */ +- (void)purchaseProductWithResult:(QONProduct *)product options:(QONPurchaseOptions *)options completion:(void(^)(QONPurchaseResult *result))completion; + /** Restore user entitlements based on purchases @param completion Completion block that includes entitlements dictionary and error diff --git a/Sources/Qonversion/Public/Qonversion.m b/Sources/Qonversion/Public/Qonversion.m index c05c402f..8434bc15 100644 --- a/Sources/Qonversion/Public/Qonversion.m +++ b/Sources/Qonversion/Public/Qonversion.m @@ -180,6 +180,12 @@ - (void)purchase:(NSString *)productID completion:(QONPurchaseCompletionHandler) [self.productCenterManager purchase:productID purchaseOptions:nil completion:completion]; } +// MARK: - New Purchase Method with PurchaseResult + +- (void)purchaseProductWithResult:(QONProduct *)product options:(QONPurchaseOptions *)options completion:(void(^)(QONPurchaseResult *result))completion { + [self.productCenterManager purchaseWithResult:product options:options completion:completion]; +} + - (void)restore:(QNRestoreCompletionHandler)completion { [self.productCenterManager restoreReceipt:completion]; } diff --git a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.h b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.h index f5077936..9e0c3df4 100644 --- a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.h +++ b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.h @@ -5,11 +5,16 @@ #import "QONRemoteConfigManager.h" #import "QONRequestTrigger.h" -@class QONLaunchResult, QONStoreKit2PurchaseModel, QONFallbackService, QONPromotionalOffer, QONPurchaseOptions, SKProductDiscount; +@class QONLaunchResult, QONStoreKit2PurchaseModel, QONFallbackService, QONPromotionalOffer, QONPurchaseOptions, QONPurchaseResult, SKProductDiscount; @protocol QONPromoPurchasesDelegate, QONEntitlementsUpdateListener, QNUserInfoServiceInterface, QNIdentityManagerInterface, QNLocalStorage; NS_ASSUME_NONNULL_BEGIN +typedef NS_ENUM(NSInteger, QONPurchaseCompletionType) { + QONPurchaseCompletionTypeLegacy = 0, + QONPurchaseCompletionTypeResult = 1 +}; + @interface QNProductCenterManager : NSObject @property (nonatomic, assign) QONLaunchMode launchMode; @@ -30,6 +35,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)checkEntitlements:(QONEntitlementsCompletionHandler)completion; - (void)purchase:(QONProduct * _Nonnull)product options:(QONPurchaseOptions * _Nullable)options completion:(nonnull QONPurchaseCompletionHandler)completion; - (void)purchase:(NSString * _Nonnull)productID purchaseOptions:(QONPurchaseOptions * _Nullable)options completion:(nonnull QONPurchaseCompletionHandler)completion; + +- (void)purchaseWithResult:(QONProduct * _Nonnull)product options:(QONPurchaseOptions * _Nullable)options completion:(void(^)(QONPurchaseResult *result))completion; + - (void)restoreTransactions:(QNRestoreCompletionHandler)completion; - (void)products:(QONProductsCompletionHandler)completion; diff --git a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m index a32f5141..e4239da4 100644 --- a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m +++ b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m @@ -23,6 +23,7 @@ #import "QONFallbackObject.h" #import "QONPromotionalOffer.h" #import "QONPurchaseOptions.h" +#import "QONPurchaseResult+Protected.h" #import #import "QONRequestTrigger.h" @@ -49,6 +50,7 @@ @interface QNProductCenterManager() @property (nonatomic, copy) NSArray *restoredTransactions; @property (nonatomic, strong) NSMutableDictionary *purchasingBlocks; +@property (nonatomic, strong) NSMutableDictionary *purchasingResultBlocks; @property (nonatomic, strong) NSMutableArray *restorePurchasesBlocks; @property (nonatomic, strong) NSMutableArray *receiptRestoreBlocks; @property (nonatomic, strong) NSMutableArray *entitlementsBlocks; @@ -106,6 +108,7 @@ - (instancetype)initWithUserInfoService:(id)userInfo _productsEntitlementsRelation = [_persistentStorage loadObjectForKey:kKeyQUserDefaultsProductsPermissionsRelation]; _purchasingBlocks = [NSMutableDictionary new]; + _purchasingResultBlocks = [NSMutableDictionary new]; _restorePurchasesBlocks = [NSMutableArray new]; _receiptRestoreBlocks = [NSMutableArray new]; _entitlementsBlocks = [NSMutableArray new]; @@ -385,6 +388,36 @@ - (void)purchase:(QONProduct *)product options:(QONPurchaseOptions *)options com } - (void)purchase:(NSString *)productID purchaseOptions:(QONPurchaseOptions *)options completion:(QONPurchaseCompletionHandler)completion { + QONProduct *product = [self QNProduct:productID]; + if (!product) { + run_block_on_main(completion, @{}, [QONErrors errorWithQONErrorCode:QONErrorProductNotFound], NO); + return; + } + + [self purchaseProduct:product + options:options + launchRequired:YES + completion:completion + completionType:QONPurchaseCompletionTypeLegacy]; +} + + +// MARK: - New Purchase Method with PurchaseResult + +- (void)purchaseWithResult:(QONProduct *)product options:(QONPurchaseOptions *)options completion:(void(^)(QONPurchaseResult *result))completion { + [self purchaseProduct:product + options:options + launchRequired:YES + completion:completion + completionType:QONPurchaseCompletionTypeResult]; +} + +- (void)purchaseProduct:(QONProduct *)product + options:(QONPurchaseOptions *)options + launchRequired:(BOOL)launchRequired + completion:(id)completion + completionType:(QONPurchaseCompletionType)type { + if (self.launchMode == QONLaunchModeAnalytics) { QONVERSION_LOG(@"⚠️ Making purchases via Qonversion in the Analytics mode can lead to an inconsistent state in the store. Consider switching to the Subscription management mode."); } @@ -393,72 +426,117 @@ - (void)purchase:(NSString *)productID purchaseOptions:(QONPurchaseOptions *)opt NSArray *storeProducts = [self.storeKitService getLoadedProducts]; if (self.launchError) { - __block __weak QNProductCenterManager *weakSelf = self; - [self launchWithTrigger:QONRequestTriggerPurchase completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { - NSDictionary *products = [weakSelf getActualProducts]; - if (error && products.count == 0) { - run_block_on_main(completion, @{}, error, NO); - return; - } - - if (weakSelf.productsLoading) { - [weakSelf prepareDelayedPurchase:productID options:options completion:completion]; - } else { - [weakSelf processPurchase:productID options:options completion:completion]; - } - }]; + [self handleLaunchErrorForProduct:product options:options completion:completion type:type]; } else if (!self.productsLoading && storeProducts.count == 0) { - [self prepareDelayedPurchase:productID options:options completion:completion]; - - [self loadProducts]; + [self handleNoProductsForProduct:product options:options completion:completion type:type]; } else { - [self processPurchase:productID options:options completion:completion]; + [self handleDirectPurchaseForProduct:product options:options completion:completion type:type]; } } } -- (void)prepareDelayedPurchase:(NSString *)productID options:(QONPurchaseOptions *)options completion:(QONPurchaseCompletionHandler)completion { +// MARK: - Private Helper Methods + +- (void)handleLaunchErrorForProduct:(QONProduct *)product + options:(QONPurchaseOptions *)options + completion:(id)completion + type:(QONPurchaseCompletionType)type { + __block __weak QNProductCenterManager *weakSelf = self; + [self launchWithTrigger:QONRequestTriggerPurchase completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + NSDictionary *products = [weakSelf getActualProducts]; + if (error && products.count == 0) { + [weakSelf handlePurchaseError:error completion:completion type:type]; + return; + } + + if (weakSelf.productsLoading) { + [weakSelf prepareDelayedPurchase:product options:options completion:completion type:type]; + } else { + [weakSelf processPurchase:product options:options completion:completion type:type]; + } + }]; +} + +- (void)handleNoProductsForProduct:(QONProduct *)product + options:(QONPurchaseOptions *)options + completion:(id)completion + type:(QONPurchaseCompletionType)type { + [self prepareDelayedPurchase:product options:options completion:completion type:type]; + [self loadProducts]; +} + +- (void)handleDirectPurchaseForProduct:(QONProduct *)product + options:(QONPurchaseOptions *)options + completion:(id)completion + type:(QONPurchaseCompletionType)type { + [self processPurchase:product options:options completion:completion type:type]; +} + +- (void)prepareDelayedPurchase:(QONProduct *)product + options:(QONPurchaseOptions *)options + completion:(id)completion + type:(QONPurchaseCompletionType)type { QONProductsCompletionHandler productsCompletion = ^(NSDictionary *result, NSError *_Nullable error) { if (error) { - run_block_on_main(completion, @{}, error, NO); + [self handlePurchaseError:error completion:completion type:type]; return; } - [self processPurchase:productID options:options completion:completion]; + [self processPurchase:product options:options completion:completion type:type]; }; [self.productsBlocks addObject:productsCompletion]; } -- (void)processPurchase:(NSString *)productID options:(QONPurchaseOptions *)options completion:(QONPurchaseCompletionHandler)completion { - QONProduct *product = [self QNProduct:productID]; +- (void)processPurchase:(QONProduct *)product + options:(QONPurchaseOptions *)options + completion:(id)completion + type:(QONPurchaseCompletionType)type { - if (!product) { - QONVERSION_LOG(@"❌ product with id: %@ not found", productID); - run_block_on_main(completion, @{}, [QONErrors errorWithQONErrorCode:QONErrorProductNotFound], NO); - return; + if (type == QONPurchaseCompletionTypeLegacy) { + if (self.purchasingBlocks[product.storeID]) { + QONVERSION_LOG(@"Purchasing in process"); + return; + } + } else { + if (self.purchasingResultBlocks[product.storeID]) { + QONVERSION_LOG(@"Purchasing in process"); + return; + } } - [self processProductPurchase:product options:options completion:completion]; -} - -- (void)processProductPurchase:(QONProduct *)product options:(QONPurchaseOptions *)options completion:(QONPurchaseCompletionHandler)completion { - if (self.purchasingBlocks[product.storeID]) { - QONVERSION_LOG(@"Purchasing in process"); - return; - } NSString *identityId = [self.userInfoService obtainCustomIdentityUserID]; if (product && [_storeKitService purchase:product.storeID options:options identityId:identityId]) { [self updatePurchaseOptions:options storeProductId:product.storeID]; - self.purchasingBlocks[product.storeID] = completion; + + if (type == QONPurchaseCompletionTypeLegacy) { + self.purchasingBlocks[product.storeID] = completion; + } else { + self.purchasingResultBlocks[product.storeID] = completion; + } return; } QONVERSION_LOG(@"❌ Store product with id: %@ not found", product.storeID); - run_block_on_main(completion, @{}, [QONErrors errorWithQONErrorCode:QONErrorProductNotFound], NO); + NSError *error = [QONErrors errorWithQONErrorCode:QONErrorProductNotFound]; + [self handlePurchaseError:error completion:completion type:type]; +} + +- (void)handlePurchaseError:(NSError *)error + completion:(id)completion + type:(QONPurchaseCompletionType)type { + if (type == QONPurchaseCompletionTypeLegacy) { + QONPurchaseCompletionHandler legacyCompletion = (QONPurchaseCompletionHandler)completion; + run_block_on_main(legacyCompletion, @{}, error, NO); + } else { + void(^resultCompletion)(QONPurchaseResult *result) = (void(^)(QONPurchaseResult *result))completion; + QONPurchaseResult *purchaseResult = [[QONPurchaseResult alloc] initWithError:error isUserCanceled:NO]; + run_block_on_main(resultCompletion, purchaseResult); + } } + - (void)restoreReceipt:(QNRestoreCompletionHandler)completion { if (completion) { [self.receiptRestoreBlocks addObject:completion]; @@ -870,11 +948,24 @@ - (void)launch:(QONRequestTrigger)requestTrigger - (void)handleFailedTransaction:(SKPaymentTransaction *)transaction forProduct:(SKProduct *)product error:(NSError *)error { QONPurchaseCompletionHandler _purchasingBlock = _purchasingBlocks[product.productIdentifier]; + void(^_purchasingResultBlock)(QONPurchaseResult *) = _purchasingResultBlocks[product.productIdentifier]; + + BOOL isUserCanceled = error.code == QONErrorCancelled; + if (_purchasingBlock) { - run_block_on_main(_purchasingBlock, @{}, error, error.code == QONErrorCancelled); - @synchronized (self) { - [_purchasingBlocks removeObjectForKey:product.productIdentifier]; - } + run_block_on_main(_purchasingBlock, @{}, error, isUserCanceled); + } + + if (_purchasingResultBlock) { + QONPurchaseResult *purchaseResult = [[QONPurchaseResult alloc] initWithError:error + transaction:transaction + isUserCanceled:isUserCanceled]; + run_block_on_main(_purchasingResultBlock, purchaseResult); + } + + @synchronized (self) { + [_purchasingBlocks removeObjectForKey:product.productIdentifier]; + [_purchasingResultBlocks removeObjectForKey:product.productIdentifier]; } } @@ -931,8 +1022,10 @@ - (void)handlePurchasedTransaction:(SKPaymentTransaction *)transaction forProduc __block NSURLRequest *request = [weakSelf.apiClient purchaseRequestWith:product transaction:transaction receipt:receipt purchaseOptions:purchaseOptions requestTrigger:requestTrigger completion:^(NSDictionary * _Nullable dict, NSError * _Nullable error) { QONPurchaseCompletionHandler _purchasingBlock = weakSelf.purchasingBlocks[product.productIdentifier]; + void(^_purchasingResultBlock)(QONPurchaseResult *) = weakSelf.purchasingResultBlocks[product.productIdentifier]; @synchronized (weakSelf) { [weakSelf.purchasingBlocks removeObjectForKey:product.productIdentifier]; + [weakSelf.purchasingResultBlocks removeObjectForKey:product.productIdentifier]; } [weakSelf removePurchaseOptionsForStoreProductId:product.productIdentifier]; @@ -978,7 +1071,17 @@ - (void)handlePurchasedTransaction:(SKPaymentTransaction *)transaction forProduc if (_purchasingBlock) { run_block_on_main(_purchasingBlock, launchResult.entitlements, error, NO); - } else { + } + + if (_purchasingResultBlock) { + QONPurchaseResult *purchaseResult = [[QONPurchaseResult alloc] initWithEntitlements:launchResult.entitlements + error:error + transaction:transaction + isUserCanceled:NO]; + run_block_on_main(_purchasingResultBlock, purchaseResult); + } + + if (!_purchasingBlock && !_purchasingResultBlock) { if (transaction.transactionState == SKPaymentTransactionStateRestored) { // One successful restored purchase result from API is enough to assume that the receipt is successfully handled by the backend if (!resultError) { diff --git a/Sources/Qonversion/Qonversion/Models/Protected/QONPurchaseResult+Protected.h b/Sources/Qonversion/Qonversion/Models/Protected/QONPurchaseResult+Protected.h new file mode 100644 index 00000000..64a8b3f2 --- /dev/null +++ b/Sources/Qonversion/Qonversion/Models/Protected/QONPurchaseResult+Protected.h @@ -0,0 +1,60 @@ +// +// QONPurchaseResult+Protected.h +// Qonversion +// +// Created by Suren Sarkisyan on 18.12.2024. +// Copyright © 2024 Qonversion Inc. All rights reserved. +// + +#import "QONPurchaseResult.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface QONPurchaseResult (Protected) + +/** + * Initialize purchase result with entitlements and transaction. + * @param entitlements Dictionary of entitlements granted + * @param transaction StoreKit transaction + * @return QONPurchaseResult instance + */ +- (instancetype)initWithEntitlements:(nullable NSDictionary *)entitlements + transaction:(nullable SKPaymentTransaction *)transaction; + +/** + * Initialize purchase result with error and cancellation status. + * @param error Error that occurred during purchase + * @param isUserCanceled Whether the user canceled the purchase + * @return QONPurchaseResult instance + */ +- (instancetype)initWithError:(nullable NSError *)error + isUserCanceled:(BOOL)isUserCanceled; + +/** + * Initialize purchase result with error, transaction and cancellation status. + * @param error Error that occurred during purchase + * @param transaction StoreKit transaction + * @param isUserCanceled Whether the user canceled the purchase + * @return QONPurchaseResult instance + */ +- (instancetype)initWithError:(nullable NSError *)error + transaction:(nullable SKPaymentTransaction *)transaction + isUserCanceled:(BOOL)isUserCanceled; + +/** + * Initialize purchase result with all parameters. + * @param entitlements Dictionary of entitlements granted + * @param error Error that occurred during purchase + * @param transaction StoreKit transaction + * @param isUserCanceled Whether the user canceled the purchase + * @return QONPurchaseResult instance + */ +- (instancetype)initWithEntitlements:(nullable NSDictionary *)entitlements + error:(nullable NSError *)error + transaction:(nullable SKPaymentTransaction *)transaction + isUserCanceled:(BOOL)isUserCanceled; + +@end + +NS_ASSUME_NONNULL_END +