From c28290395ac13bb887e55bc1c5aa2c04ba0eccf7 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Tue, 13 Jan 2026 13:54:46 +0300 Subject: [PATCH] Added purchase method with result --- .../reactnativesdk/QonversionModule.kt | 27 ++++++ example/src/screens/NoCodesScreen/index.tsx | 19 ++-- .../src/screens/ProductDetailScreen/index.tsx | 50 +++++++--- ios/RNQonversion.mm | 5 + ios/RNQonversionImpl.swift | 7 ++ src/QonversionApi.ts | 18 +++- src/dto/PurchaseResult.ts | 88 +++++++++++++++++ src/dto/StoreTransaction.ts | 61 ++++++++++++ src/dto/enums.ts | 40 ++++++++ src/index.ts | 2 + src/internal/Mapper.ts | 94 +++++++++++++++++++ src/internal/QonversionInternal.ts | 50 +++++++++- src/internal/specs/NativeQonversionModule.ts | 14 ++- 13 files changed, 449 insertions(+), 26 deletions(-) create mode 100644 src/dto/PurchaseResult.ts create mode 100644 src/dto/StoreTransaction.ts diff --git a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt index bc73b0d..0565d17 100644 --- a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt +++ b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt @@ -89,6 +89,33 @@ class QonversionModule(reactContext: ReactApplicationContext) : NativeQonversion ) } + @ReactMethod + override fun purchaseWithResult( + productId: String, + quantity: Double, + contextKeys: ReadableArray?, + promoOffer: ReadableMap?, + offerId: String?, + applyOffer: Boolean, + oldProductId: String?, + updatePolicyKey: String?, + promise: Promise + ) { + var contextKeysList: List? = null + if (contextKeys != null) { + contextKeysList = EntitiesConverter.convertArrayToStringList(contextKeys) + } + qonversionSandwich.purchaseWithResult( + productId, + offerId, + applyOffer, + oldProductId, + updatePolicyKey, + contextKeysList, + getResultListener(promise) + ) + } + @ReactMethod override fun updatePurchase( productId: String, diff --git a/example/src/screens/NoCodesScreen/index.tsx b/example/src/screens/NoCodesScreen/index.tsx index 9c2d9bc..4c1edab 100644 --- a/example/src/screens/NoCodesScreen/index.tsx +++ b/example/src/screens/NoCodesScreen/index.tsx @@ -44,18 +44,23 @@ const NoCodesScreen: React.FC = () => { const purchaseDelegate: PurchaseDelegate = { purchase: async (product: Product) => { console.log('🔄 [PurchaseDelegate] Starting purchase for product:', product.qonversionId); - try { - const entitlements = await Qonversion.getSharedInstance().purchaseProduct(product); - console.log('✅ [PurchaseDelegate] Purchase successful:', Object.fromEntries(entitlements)); + const result = await Qonversion.getSharedInstance().purchaseWithResult(product); + console.log('✅ [PurchaseDelegate] Purchase result:', result.status); + + if (result.isSuccess) { dispatch({ type: 'ADD_NOCODES_EVENT', payload: `Purchase completed: ${product.qonversionId}` }); Snackbar.show({ text: `Purchase completed: ${product.qonversionId}`, duration: Snackbar.LENGTH_SHORT, }); - } catch (error: any) { - console.error('❌ [PurchaseDelegate] Purchase failed:', error); - dispatch({ type: 'ADD_NOCODES_EVENT', payload: `Purchase failed: ${error.message}` }); - throw error; // Re-throw to let NoCodes SDK handle the error + } else if (result.isCanceled) { + dispatch({ type: 'ADD_NOCODES_EVENT', payload: `Purchase canceled: ${product.qonversionId}` }); + } else if (result.isPending) { + dispatch({ type: 'ADD_NOCODES_EVENT', payload: `Purchase pending: ${product.qonversionId}` }); + } else if (result.isError) { + console.error('❌ [PurchaseDelegate] Purchase failed:', result.error); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: `Purchase failed: ${result.error?.description}` }); + throw new Error(result.error?.description || 'Purchase failed'); } }, restore: async () => { diff --git a/example/src/screens/ProductDetailScreen/index.tsx b/example/src/screens/ProductDetailScreen/index.tsx index f865979..7a0e7e3 100644 --- a/example/src/screens/ProductDetailScreen/index.tsx +++ b/example/src/screens/ProductDetailScreen/index.tsx @@ -20,26 +20,48 @@ const ProductDetailScreen: React.FC = ({ const purchaseProduct = async () => { try { console.log( - '🔄 [Qonversion] Starting purchaseProduct() call with product:', + '🔄 [Qonversion] Starting purchaseWithResult() call with product:', product ); dispatch({ type: 'SET_LOADING', payload: true }); - const entitlements = - await Qonversion.getSharedInstance().purchaseProduct(product); + const result = + await Qonversion.getSharedInstance().purchaseWithResult(product); console.log( - '✅ [Qonversion] purchaseProduct() call successful:', - Object.fromEntries(entitlements) + '✅ [Qonversion] purchaseWithResult() call completed with status:', + result.status ); - dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); - Snackbar.show({ - text: 'Product purchased successfully!', - duration: Snackbar.LENGTH_SHORT, - }); - } catch (error: any) { - console.error('❌ [Qonversion] purchaseProduct() call failed:', error); - if (!error.userCanceled) { - Alert.alert('Error', error.message); + + console.log('đŸ“Ļ [Qonversion] Purchase result:', result); + + if (result.isSuccess) { + console.log('✅ [Qonversion] Purchase successful!'); + if (result.entitlements) { + dispatch({ type: 'SET_ENTITLEMENTS', payload: result.entitlements }); + } + Snackbar.show({ + text: 'Product purchased successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } else if (result.isCanceled) { + console.log('â„šī¸ [Qonversion] Purchase canceled by user'); + Snackbar.show({ + text: 'Purchase was canceled', + duration: Snackbar.LENGTH_SHORT, + }); + } else if (result.isPending) { + console.log('âŗ [Qonversion] Purchase is pending'); + Snackbar.show({ + text: 'Purchase is pending approval', + duration: Snackbar.LENGTH_SHORT, + }); + } else if (result.isError) { + console.error('❌ [Qonversion] Purchase failed with error:', result.error); + Alert.alert('Purchase Error', result.error?.description || 'An error occurred'); } + + } catch (error: any) { + console.error('❌ [Qonversion] purchaseWithResult() call failed:', error); + Alert.alert('Error', error.message); } finally { dispatch({ type: 'SET_LOADING', payload: false }); } diff --git a/ios/RNQonversion.mm b/ios/RNQonversion.mm index 0370424..5a79412 100644 --- a/ios/RNQonversion.mm +++ b/ios/RNQonversion.mm @@ -47,6 +47,11 @@ - (void)purchase:(nonnull NSString *)productId quantity:(double)quantity context [self.impl purchase:productId quantity:quantity contextKeys:contextKeys promoOffer:promoOfferDict resolve:resolve reject:reject]; } +- (void)purchaseWithResult:(nonnull NSString *)productId quantity:(double)quantity contextKeys:(NSArray * _Nullable)contextKeys promoOffer:(JS::NativeQonversionModule::QPromoOfferDetails &)promoOffer offerId:(NSString * _Nullable)offerId applyOffer:(BOOL)applyOffer oldProductId:(NSString * _Nullable)oldProductId updatePolicyKey:(NSString * _Nullable)updatePolicyKey resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject { + NSDictionary *promoOfferDict = convertPromoOfferDetailsToDictionary(promoOffer); + [self.impl purchaseWithResult:productId quantity:quantity contextKeys:contextKeys promoOffer:promoOfferDict resolve:resolve reject:reject]; +} + - (void)setDefinedProperty:(NSString *)property value:(NSString *)value { [self.impl setDefinedProperty:property value:value]; } diff --git a/ios/RNQonversionImpl.swift b/ios/RNQonversionImpl.swift index 14cd8c1..917d373 100644 --- a/ios/RNQonversionImpl.swift +++ b/ios/RNQonversionImpl.swift @@ -80,6 +80,13 @@ public class RNQonversionImpl: NSObject { }) } + @objc + public func purchaseWithResult(_ productId: String, quantity: Int, contextKeys: [String], promoOffer: [String: Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + qonversionSandwich?.purchaseWithResult(productId, quantity: quantity, contextKeys: contextKeys, promoOffer: promoOffer, completion: { result, error in + self.handleResult(result: result, error: error, resolve: resolve, reject: reject) + }) + } + @objc public func setDefinedProperty(_ property: String, value: String) { qonversionSandwich?.setDefinedProperty(property, value: value) diff --git a/src/QonversionApi.ts b/src/QonversionApi.ts index f691967..144cc96 100644 --- a/src/QonversionApi.ts +++ b/src/QonversionApi.ts @@ -12,6 +12,7 @@ import UserProperties from './dto/UserProperties'; import PurchaseModel from './dto/PurchaseModel'; import PurchaseUpdateModel from './dto/PurchaseUpdateModel'; import PurchaseOptions from "./dto/PurchaseOptions"; +import PurchaseResult from "./dto/PurchaseResult"; import SKProductDiscount from './dto/storeProducts/SKProductDiscount'; import PromotionalOffer from './dto/PromotionalOffer'; @@ -48,12 +49,25 @@ export interface QonversionApi { getPromotionalOffer(product: Product, discount: SKProductDiscount): Promise; + /** + * Make a purchase and validate it through server-to-server using Qonversion's Backend. + * Returns a detailed PurchaseResult with status, entitlements, and store transaction details. + * + * @param product product to purchase + * @param options additional options for the purchase process. + * @returns the promise with the PurchaseResult containing status, entitlements, and transaction details + * + * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) + */ + purchaseWithResult(product: Product, options?: PurchaseOptions | undefined): Promise + /** * Make a purchase and validate it through server-to-server using Qonversion's Backend * @param product product to purchase * @param options additional options for the purchase process. * @returns the promise with the user entitlements including the ones obtained by the purchase * + * @deprecated Use {@link purchaseWithResult} function instead. * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) */ purchaseProduct(product: Product, options?: PurchaseOptions | undefined): Promise> @@ -61,7 +75,7 @@ export interface QonversionApi { /** * Make a purchase and validate it through server-to-server using Qonversion's Backend. * - * @deprecated Use {@link purchaseProduct} function instead. + * @deprecated Use {@link purchaseWithResult} function instead. * @param purchaseModel necessary information for purchase * @returns the promise with the user entitlements including the ones obtained by the purchase * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) @@ -73,7 +87,7 @@ export interface QonversionApi { * * Update (upgrade/downgrade) subscription on Google Play Store and validate it through server-to-server using Qonversion's Backend. * - * @deprecated Use {@link purchaseProduct} function instead. + * @deprecated Use {@link purchaseWithResult} function instead. * @param purchaseUpdateModel necessary information for purchase update * @returns the promise with the user entitlements including updated ones. * @see [Update policy](https://developer.android.com/google/play/billing/subscriptions#replacement-modes) diff --git a/src/dto/PurchaseResult.ts b/src/dto/PurchaseResult.ts new file mode 100644 index 0000000..20e9949 --- /dev/null +++ b/src/dto/PurchaseResult.ts @@ -0,0 +1,88 @@ +import Entitlement from './Entitlement'; +import QonversionError from './QonversionError'; +import StoreTransaction from './StoreTransaction'; +import { PurchaseResultStatus, PurchaseResultSource } from './enums'; + +/** + * Represents the result of a purchase operation. + * Contains the status of the purchase, entitlements, and store transaction details. + */ +class PurchaseResult { + /** + * The status of the purchase operation. + */ + status: PurchaseResultStatus; + + /** + * The user's entitlements after the purchase. + * May be null if the purchase failed or is pending. + */ + entitlements: Map | null; + + /** + * The error that occurred during the purchase, if any. + */ + error: QonversionError | null; + + /** + * Indicates whether the entitlements were generated from a fallback source. + */ + isFallbackGenerated: boolean; + + /** + * The source of the purchase result data. + */ + source: PurchaseResultSource; + + /** + * The store transaction details from the native platform. + * Contains raw transaction information from Apple App Store or Google Play Store. + */ + storeTransaction: StoreTransaction | null; + + constructor( + status: PurchaseResultStatus, + entitlements: Map | null, + error: QonversionError | null, + isFallbackGenerated: boolean, + source: PurchaseResultSource, + storeTransaction: StoreTransaction | null, + ) { + this.status = status; + this.entitlements = entitlements; + this.error = error; + this.isFallbackGenerated = isFallbackGenerated; + this.source = source; + this.storeTransaction = storeTransaction; + } + + /** + * Returns true if the purchase was successful. + */ + get isSuccess(): boolean { + return this.status === PurchaseResultStatus.SUCCESS; + } + + /** + * Returns true if the purchase was canceled by the user. + */ + get isCanceled(): boolean { + return this.status === PurchaseResultStatus.USER_CANCELED; + } + + /** + * Returns true if the purchase is pending. + */ + get isPending(): boolean { + return this.status === PurchaseResultStatus.PENDING; + } + + /** + * Returns true if an error occurred during the purchase. + */ + get isError(): boolean { + return this.status === PurchaseResultStatus.ERROR; + } +} + +export default PurchaseResult; diff --git a/src/dto/StoreTransaction.ts b/src/dto/StoreTransaction.ts new file mode 100644 index 0000000..6ef8cf9 --- /dev/null +++ b/src/dto/StoreTransaction.ts @@ -0,0 +1,61 @@ +/** + * Represents a raw store transaction from the native platform. + * This is the transaction information as received from Apple App Store or Google Play Store. + */ +class StoreTransaction { + /** + * The unique identifier for this transaction. + */ + transactionId?: string; + + /** + * The original transaction identifier. + * For subscriptions, this identifies the first transaction in the subscription chain. + */ + originalTransactionId?: string; + + /** + * The date and time when the transaction occurred. + */ + transactionDate?: Date; + + /** + * The store product identifier associated with this transaction. + */ + productId?: string; + + /** + * The quantity of items purchased. + */ + quantity: number; + + /** + * iOS only. The identifier of the promotional offer applied to this purchase. + */ + promoOfferId?: string; + + /** + * Android only. The purchase token from Google Play. + */ + purchaseToken?: string; + + constructor( + transactionId: string | undefined, + originalTransactionId: string | undefined, + transactionTimestamp: number | undefined, + productId: string | undefined, + quantity: number | undefined, + promoOfferId: string | undefined, + purchaseToken: string | undefined, + ) { + this.transactionId = transactionId; + this.originalTransactionId = originalTransactionId; + this.transactionDate = transactionTimestamp ? new Date(transactionTimestamp) : undefined; + this.productId = productId; + this.quantity = quantity ?? 1; + this.promoOfferId = promoOfferId; + this.purchaseToken = purchaseToken; + } +} + +export default StoreTransaction; diff --git a/src/dto/enums.ts b/src/dto/enums.ts index 88cefe3..3d8ab15 100644 --- a/src/dto/enums.ts +++ b/src/dto/enums.ts @@ -388,3 +388,43 @@ export enum NoCodesErrorCode { SCREEN_LOADING_FAILED = "ScreenLoadingFailed", // iOS SDK_INITIALIZATION_ERROR = "SDKInitializationError" // iOS } + +/** + * Status of the purchase result. + */ +export enum PurchaseResultStatus { + /** + * The purchase was successful. + */ + SUCCESS = "Success", + + /** + * The purchase was canceled by the user. + */ + USER_CANCELED = "UserCanceled", + + /** + * The purchase is pending (e.g., waiting for parental approval). + */ + PENDING = "Pending", + + /** + * An error occurred during the purchase. + */ + ERROR = "Error", +} + +/** + * Source of the purchase result data. + */ +export enum PurchaseResultSource { + /** + * The result was obtained from the Qonversion API. + */ + API = "Api", + + /** + * The result was obtained from the local store. + */ + LOCAL = "Local", +} diff --git a/src/index.ts b/src/index.ts index ba6c56c..f54cbff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,8 @@ export { default as SKProductDiscount } from './dto/storeProducts/SKProductDisco export { default as SKSubscriptionPeriod } from './dto/storeProducts/SKSubscriptionPeriod'; export { default as PurchaseOptionsBuilder } from './dto/PurchaseOptionsBuilder'; export { default as PurchaseOptions } from './dto/PurchaseOptionsBuilder'; +export { default as PurchaseResult } from './dto/PurchaseResult'; +export { default as StoreTransaction } from './dto/StoreTransaction'; // NoCode exports export { default as ScreenPresentationConfig } from './dto/ScreenPresentationConfig'; diff --git a/src/internal/Mapper.ts b/src/internal/Mapper.ts index a057f3a..a2671e1 100644 --- a/src/internal/Mapper.ts +++ b/src/internal/Mapper.ts @@ -8,6 +8,8 @@ import { PricingPhaseRecurrenceMode, PricingPhaseType, ProductType, + PurchaseResultSource, + PurchaseResultStatus, QonversionErrorCode, RemoteConfigurationAssignmentType, RemoteConfigurationSourceType, @@ -52,6 +54,8 @@ import SKPaymentDiscount from '../dto/storeProducts/SKPaymentDiscount'; import NoCodesAction from '../dto/NoCodesAction'; import QonversionError from '../dto/QonversionError'; import NoCodesError from '../dto/NoCodesError'; +import PurchaseResult from '../dto/PurchaseResult'; +import StoreTransaction from '../dto/StoreTransaction'; export type QProduct = { id: string; @@ -298,6 +302,25 @@ export type QNoCodesError = QQonversionError & { export type QNoCodeScreenInfo = { screenId: string }; +export type QStoreTransaction = { + transactionId?: string | null; + originalTransactionId?: string | null; + transactionTimestamp?: number | null; + productId?: string | null; + quantity?: number | null; + promoOfferId?: string | null; + purchaseToken?: string | null; +}; + +export type QPurchaseResult = { + status: string; + entitlements: Record | null; + error: QQonversionError | null | undefined; + isFallbackGenerated: boolean; + source: string; + storeTransaction: QStoreTransaction | null | undefined; +}; + const priceMicrosRatio = 1000000; class Mapper { @@ -1156,6 +1179,77 @@ class Mapper { return QonversionErrorCode.UNKNOWN; } + + // region PurchaseResult Mappers + + static convertPurchaseResult( + purchaseResult: QPurchaseResult | null | undefined + ): PurchaseResult | null { + if (!purchaseResult) { + return null; + } + + const status = this.convertPurchaseResultStatus(purchaseResult.status); + const entitlements = purchaseResult.entitlements + ? this.convertEntitlements(purchaseResult.entitlements) + : null; + const error = purchaseResult.error ? this.convertQonversionError(purchaseResult.error) : undefined; + const source = this.convertPurchaseResultSource(purchaseResult.source); + const storeTransaction = this.convertStoreTransaction(purchaseResult.storeTransaction); + + return new PurchaseResult( + status, + entitlements, + error ?? null, + purchaseResult.isFallbackGenerated ?? false, + source, + storeTransaction + ); + } + + static convertPurchaseResultStatus(status: string | null | undefined): PurchaseResultStatus { + switch (status) { + case "Success": + return PurchaseResultStatus.SUCCESS; + case "UserCanceled": + return PurchaseResultStatus.USER_CANCELED; + case "Pending": + return PurchaseResultStatus.PENDING; + case "Error": + default: + return PurchaseResultStatus.ERROR; + } + } + + static convertPurchaseResultSource(source: string | null | undefined): PurchaseResultSource { + switch (source) { + case "Local": + return PurchaseResultSource.LOCAL; + case "Api": + default: + return PurchaseResultSource.API; + } + } + + static convertStoreTransaction( + storeTransaction: QStoreTransaction | null | undefined + ): StoreTransaction | null { + if (!storeTransaction) { + return null; + } + + return new StoreTransaction( + storeTransaction.transactionId ?? undefined, + storeTransaction.originalTransactionId ?? undefined, + storeTransaction.transactionTimestamp ?? undefined, + storeTransaction.productId ?? undefined, + storeTransaction.quantity ?? undefined, + storeTransaction.promoOfferId ?? undefined, + storeTransaction.purchaseToken ?? undefined + ); + } + + // endregion } export default Mapper; diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index 69bbf27..e723766 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -1,10 +1,11 @@ import {AttributionProvider, QonversionErrorCode, UserPropertyKey} from "../dto/enums"; import IntroEligibility from "../dto/IntroEligibility"; import Mapper from "./Mapper"; -import type {QEntitlement, QEligibilityInfo, QProduct} from "./Mapper"; +import type {QEntitlement, QEligibilityInfo, QProduct, QPurchaseResult} from "./Mapper"; import Offerings from "../dto/Offerings"; import Entitlement from "../dto/Entitlement"; import Product from "../dto/Product"; +import PurchaseResult from "../dto/PurchaseResult"; import {isAndroid, isIos} from "./utils"; import type {EntitlementsUpdateListener} from '../dto/EntitlementsUpdateListener'; import type {PromoPurchasesListener} from '../dto/PromoPurchasesListener'; @@ -73,6 +74,53 @@ export default class QonversionInternal implements QonversionApi { return mappedPromoOffer; } + async purchaseWithResult(product: Product, options: PurchaseOptions | undefined): Promise { + if (!options) { + options = new PurchaseOptionsBuilder().build(); + } + + const promoOffer: QPromoOfferDetails = { + productDiscountId: options.promotionalOffer?.productDiscount.identifier, + keyIdentifier: options.promotionalOffer?.paymentDiscount.keyIdentifier, + nonce: options.promotionalOffer?.paymentDiscount.nonce, + signature: options.promotionalOffer?.paymentDiscount.signature, + timestamp: options.promotionalOffer?.paymentDiscount.timestamp + }; + + let purchaseResultPromise: Promise; + if (isIos()) { + purchaseResultPromise = RNQonversion.purchaseWithResult( + product.qonversionId, + options.quantity, + options.contextKeys, + promoOffer, + undefined, + false, + undefined, + undefined, + ); + } else { + purchaseResultPromise = RNQonversion.purchaseWithResult( + product.qonversionId, + 1, + options.contextKeys, + undefined, + options.offerId, + options.applyOffer, + options.oldProduct?.qonversionId, + options.updatePolicy, + ); + } + const purchaseResult = await purchaseResultPromise; + const mappedResult = Mapper.convertPurchaseResult(purchaseResult); + + if (!mappedResult) { + throw new Error("Failed to parse PurchaseResult"); + } + + return mappedResult; + } + async purchaseProduct(product: Product, options: PurchaseOptions | undefined): Promise> { try { if (!options) { diff --git a/src/internal/specs/NativeQonversionModule.ts b/src/internal/specs/NativeQonversionModule.ts index 3d5efdf..f757404 100644 --- a/src/internal/specs/NativeQonversionModule.ts +++ b/src/internal/specs/NativeQonversionModule.ts @@ -1,6 +1,6 @@ import type {TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native'; -import type { QPromotionalOffer, QOfferings, QUser, QUserProperties, QRemoteConfig, QRemoteConfigList } from '../Mapper'; +import type { QPromotionalOffer, QOfferings, QUser, QUserProperties, QRemoteConfig, QRemoteConfigList, QPurchaseResult } from '../Mapper'; import type { EventEmitter } from 'react-native/Libraries/Types/CodegenTypes'; export type QPromoOfferDetails = { @@ -34,7 +34,17 @@ export interface Spec extends TurboModule { applyOffer: boolean, // Android only oldProductId: string | null | undefined, // Android only updatePolicyKey: string | null | undefined, // Android only - ): Promise; // Record; // Record + purchaseWithResult( + productId: string, + quantity: number, // iOS only + contextKeys: string[] | null | undefined, + promoOffer: QPromoOfferDetails | undefined, // iOS only + offerId: string | null | undefined, // Android only + applyOffer: boolean, // Android only + oldProductId: string | null | undefined, // Android only + updatePolicyKey: string | null | undefined, // Android only + ): Promise; updatePurchase( productId: string, offerId: string | null | undefined,