diff --git a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt index 0565d17..2944c74 100644 --- a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt +++ b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt @@ -299,6 +299,11 @@ class QonversionModule(reactContext: ReactApplicationContext) : NativeQonversion emitOnEntitlementsUpdated(mappedEntitlements) } + override fun onDeferredPurchaseCompleted(transaction: BridgeData) { + val mappedTransaction = EntitiesConverter.convertMapToWritableMap(transaction) + emitOnDeferredPurchaseCompleted(mappedTransaction) + } + companion object { const val NAME = "RNQonversion" } diff --git a/ios/RNQonversion.mm b/ios/RNQonversion.mm index 2088da9..77b4277 100644 --- a/ios/RNQonversion.mm +++ b/ios/RNQonversion.mm @@ -317,6 +317,14 @@ - (void)qonversionDidReceiveUpdatedEntitlements:(NSDictionary * _ } } +- (void)qonversionDidCompleteDeferredPurchase:(NSDictionary * _Nonnull)transaction { + @try { + [self emitOnDeferredPurchaseCompleted:transaction]; + } @catch (NSException *exception) { + QNR_LOG_EXCEPTION("qonversionDidCompleteDeferredPurchase", exception); + } +} + - (void)shouldPurchasePromoProductWith:(NSString * _Nonnull)productId { @try { [self emitOnPromoPurchaseReceived:productId]; diff --git a/ios/RNQonversionImpl.swift b/ios/RNQonversionImpl.swift index 917d373..5d05de7 100644 --- a/ios/RNQonversionImpl.swift +++ b/ios/RNQonversionImpl.swift @@ -13,6 +13,7 @@ import React public protocol QonversionEventDelegate { func shouldPurchasePromoProduct(with productId: String) func qonversionDidReceiveUpdatedEntitlements(_ entitlements: [String: Any]) + func qonversionDidCompleteDeferredPurchase(_ transaction: [String: Any]) } class QonversionEventHandler: QonversionEventListener { @@ -25,6 +26,10 @@ class QonversionEventHandler: QonversionEventListener { func qonversionDidReceiveUpdatedEntitlements(_ entitlements: [String: Any]) { delegate?.qonversionDidReceiveUpdatedEntitlements(entitlements) } + + func qonversionDidCompleteDeferredPurchase(_ transaction: [String: Any]) { + delegate?.qonversionDidCompleteDeferredPurchase(transaction) + } } @objc diff --git a/src/QonversionApi.ts b/src/QonversionApi.ts index 144cc96..c1ce931 100644 --- a/src/QonversionApi.ts +++ b/src/QonversionApi.ts @@ -5,6 +5,7 @@ import Offerings from './dto/Offerings'; import IntroEligibility from './dto/IntroEligibility'; import User from './dto/User'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; import type {PromoPurchasesListener} from './dto/PromoPurchasesListener'; import RemoteConfig from "./dto/RemoteConfig"; import RemoteConfigList from "./dto/RemoteConfigList"; @@ -239,9 +240,28 @@ export interface QonversionApi { * with {@link Qonversion.initialize}. * * @param listener listener to be called when entitlements update + * @deprecated Use {@link setDeferredPurchasesListener} instead. */ setEntitlementsUpdateListener(listener: EntitlementsUpdateListener): void; + /** + * Provide a listener to be notified about deferred purchase completions. + * + * Deferred purchases happen when transactions require additional steps to complete, + * such as SCA (Strong Customer Authentication), Ask to Buy, or other pending transactions. + * This listener will be called when such purchases are finalized. + * + * Make sure you provide this listener for being up-to-date with deferred purchase completions. + * Also, please, consider that this listener should live for the whole lifetime of the application. + * + * You may set deferred purchases listener both *after* Qonversion SDK initializing + * with {@link QonversionApi.setDeferredPurchasesListener} and *while* Qonversion initializing + * with {@link Qonversion.initialize}. + * + * @param listener listener to be called when a deferred purchase completes + */ + setDeferredPurchasesListener(listener: DeferredPurchasesListener): void; + /** * iOS only. Does nothing if called on Android. * diff --git a/src/QonversionConfig.ts b/src/QonversionConfig.ts index 3509b14..a326d1a 100644 --- a/src/QonversionConfig.ts +++ b/src/QonversionConfig.ts @@ -1,14 +1,17 @@ import {EntitlementsCacheLifetime, Environment, LaunchMode} from './dto/enums'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; class QonversionConfig { readonly projectKey: string; readonly launchMode: LaunchMode; readonly environment: Environment; readonly entitlementsCacheLifetime: EntitlementsCacheLifetime; + /** @deprecated Use {@link deferredPurchasesListener} instead. */ readonly entitlementsUpdateListener: EntitlementsUpdateListener | undefined; readonly proxyUrl: string | undefined; readonly kidsMode: boolean; + readonly deferredPurchasesListener: DeferredPurchasesListener | undefined; constructor( projectKey: string, @@ -16,8 +19,9 @@ class QonversionConfig { environment: Environment = Environment.PRODUCTION, entitlementsCacheLifetime: EntitlementsCacheLifetime = EntitlementsCacheLifetime.MONTH, entitlementsUpdateListener: EntitlementsUpdateListener | undefined = undefined, - proxyUrl: string | undefined, - kidsMode: boolean = false + proxyUrl: string | undefined = undefined, + kidsMode: boolean = false, + deferredPurchasesListener: DeferredPurchasesListener | undefined = undefined, ) { this.projectKey = projectKey; this.launchMode = launchMode; @@ -26,6 +30,7 @@ class QonversionConfig { this.entitlementsUpdateListener = entitlementsUpdateListener; this.proxyUrl = proxyUrl; this.kidsMode = kidsMode; + this.deferredPurchasesListener = deferredPurchasesListener; } } diff --git a/src/QonversionConfigBuilder.ts b/src/QonversionConfigBuilder.ts index 03e606f..fad1f1d 100644 --- a/src/QonversionConfigBuilder.ts +++ b/src/QonversionConfigBuilder.ts @@ -1,5 +1,6 @@ import {EntitlementsCacheLifetime, Environment, LaunchMode} from './dto/enums'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; import QonversionConfig from './QonversionConfig'; class QonversionConfigBuilder { @@ -14,6 +15,7 @@ class QonversionConfigBuilder { private environment: Environment = Environment.PRODUCTION; private entitlementsCacheLifetime: EntitlementsCacheLifetime = EntitlementsCacheLifetime.MONTH; private entitlementsUpdateListener: EntitlementsUpdateListener | undefined = undefined; + private deferredPurchasesListener: DeferredPurchasesListener | undefined = undefined; private proxyUrl: string | undefined = undefined; private kidsMode: boolean = false; @@ -51,12 +53,31 @@ class QonversionConfigBuilder { * * @param entitlementsUpdateListener listener to be called when entitlements update. * @return builder instance for chain calls. + * @deprecated Use {@link setDeferredPurchasesListener} instead. */ setEntitlementsUpdateListener(entitlementsUpdateListener: EntitlementsUpdateListener): QonversionConfigBuilder { this.entitlementsUpdateListener = entitlementsUpdateListener; return this; } + /** + * Provide a listener to be notified about deferred purchase completions. + * + * Deferred purchases happen when transactions require additional steps to complete, + * such as SCA (Strong Customer Authentication), Ask to Buy, or other pending transactions. + * This listener will be called when such purchases are finalized. + * + * Make sure you provide this listener for being up-to-date with deferred purchase completions. + * Also, please, consider that this listener should live for the whole lifetime of the application. + * + * @param listener listener to be called when a deferred purchase completes. + * @return builder instance for chain calls. + */ + setDeferredPurchasesListener(listener: DeferredPurchasesListener): QonversionConfigBuilder { + this.deferredPurchasesListener = listener; + return this; + } + /** * Provide a URL to your proxy server which will redirect all the requests from the app * to our API. Please, contact us before using this feature. @@ -94,7 +115,8 @@ class QonversionConfigBuilder { this.entitlementsCacheLifetime, this.entitlementsUpdateListener, this.proxyUrl, - this.kidsMode + this.kidsMode, + this.deferredPurchasesListener, ) } } diff --git a/src/dto/DeferredPurchasesListener.ts b/src/dto/DeferredPurchasesListener.ts new file mode 100644 index 0000000..aabc01d --- /dev/null +++ b/src/dto/DeferredPurchasesListener.ts @@ -0,0 +1,12 @@ +import DeferredTransaction from './DeferredTransaction'; + +export interface DeferredPurchasesListener { + + /** + * Called when a deferred purchase completes. + * For example, when pending purchases like SCA, Ask to buy, etc., are approved and finalized. + * Provides full transaction details, including for consumable products without entitlements. + * @param transaction the completed deferred transaction with full details. + */ + onDeferredPurchaseCompleted(transaction: DeferredTransaction): void; +} diff --git a/src/dto/DeferredTransaction.ts b/src/dto/DeferredTransaction.ts new file mode 100644 index 0000000..224b705 --- /dev/null +++ b/src/dto/DeferredTransaction.ts @@ -0,0 +1,51 @@ +/** + * Represents a completed deferred purchase transaction with full details. + */ +export default class DeferredTransaction { + /** + * Store product identifier. + */ + productId: string; + + /** + * Store transaction identifier. + */ + transactionId: string | null; + + /** + * Original store transaction identifier. + * For subscriptions, this is the ID of the first transaction. + */ + originalTransactionId: string | null; + + /** + * Type of the transaction: Subscription, Consumable, NonConsumable, or Unknown. + */ + type: string; + + /** + * Transaction value. May be 0 if unavailable. + */ + value: number; + + /** + * Currency code (e.g. "USD"). May be null if unavailable. + */ + currency: string | null; + + constructor( + productId: string, + transactionId: string | null, + originalTransactionId: string | null, + type: string, + value: number, + currency: string | null + ) { + this.productId = productId; + this.transactionId = transactionId; + this.originalTransactionId = originalTransactionId; + this.type = type; + this.value = value; + this.currency = currency; + } +} diff --git a/src/dto/EntitlementsUpdateListener.ts b/src/dto/EntitlementsUpdateListener.ts index 48bbef5..54eab72 100644 --- a/src/dto/EntitlementsUpdateListener.ts +++ b/src/dto/EntitlementsUpdateListener.ts @@ -1,5 +1,8 @@ import Entitlement from './Entitlement'; +/** + * @deprecated Use {@link DeferredPurchasesListener} instead. + */ export interface EntitlementsUpdateListener { /** diff --git a/src/index.ts b/src/index.ts index f54cbff..44f2dbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export { default as QonversionConfig } from './QonversionConfig'; export { default as QonversionConfigBuilder } from './QonversionConfigBuilder'; export type { EntitlementsUpdateListener } from './dto/EntitlementsUpdateListener'; +export type { DeferredPurchasesListener } from './dto/DeferredPurchasesListener'; +export { default as DeferredTransaction } from './dto/DeferredTransaction'; export * from './dto/enums'; export { default as IntroEligibility } from './dto/IntroEligibility'; export { default as Offering } from './dto/Offering'; diff --git a/src/internal/Mapper.ts b/src/internal/Mapper.ts index a2671e1..13be121 100644 --- a/src/internal/Mapper.ts +++ b/src/internal/Mapper.ts @@ -56,6 +56,7 @@ import QonversionError from '../dto/QonversionError'; import NoCodesError from '../dto/NoCodesError'; import PurchaseResult from '../dto/PurchaseResult'; import StoreTransaction from '../dto/StoreTransaction'; +import DeferredTransaction from '../dto/DeferredTransaction'; export type QProduct = { id: string; @@ -1250,6 +1251,27 @@ class Mapper { } // endregion + + // region DeferredTransaction + + static convertDeferredTransaction( + transaction: Record | null | undefined + ): DeferredTransaction | null { + if (!transaction) { + return null; + } + + return new DeferredTransaction( + transaction.productId ?? '', + transaction.transactionId ?? null, + transaction.originalTransactionId ?? null, + transaction.type ?? 'Unknown', + transaction.value ?? 0, + transaction.currency ?? null + ); + } + + // endregion } export default Mapper; diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index b69a12a..979f3af 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -8,6 +8,7 @@ import Product from "../dto/Product"; import PurchaseResult from "../dto/PurchaseResult"; import {isAndroid, isIos} from "./utils"; import type {EntitlementsUpdateListener} from '../dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from '../dto/DeferredPurchasesListener'; import type {PromoPurchasesListener} from '../dto/PromoPurchasesListener'; import User from '../dto/User'; import PurchaseOptions from '../dto/PurchaseOptions'; @@ -30,7 +31,10 @@ export const sdkSource = "rn"; export default class QonversionInternal implements QonversionApi { private entitlementsUpdateListener: EntitlementsUpdateListener | null = null; + private deferredPurchasesListener: DeferredPurchasesListener | null = null; private promoPurchasesDelegate: PromoPurchasesListener | null = null; + private entitlementsEventSubscribed = false; + private deferredPurchaseEventSubscribed = false; constructor(qonversionConfig: QonversionConfig) { RNQonversion.storeSDKInfo(sdkSource, sdkVersion); @@ -46,6 +50,10 @@ export default class QonversionInternal implements QonversionApi { if (qonversionConfig.entitlementsUpdateListener) { this.setEntitlementsUpdateListener(qonversionConfig.entitlementsUpdateListener); } + + if (qonversionConfig.deferredPurchasesListener) { + this.setDeferredPurchasesListener(qonversionConfig.deferredPurchasesListener); + } } syncHistoricalData () { @@ -382,11 +390,34 @@ export default class QonversionInternal implements QonversionApi { return; } + private subscribeToEntitlementsEvent() { + if (!this.entitlementsEventSubscribed) { + RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); + this.entitlementsEventSubscribed = true; + } + } + + private subscribeToDeferredPurchaseEvent() { + if (!this.deferredPurchaseEventSubscribed) { + RNQonversion.onDeferredPurchaseCompleted(this.deferredPurchaseCompletedEventHandler); + this.deferredPurchaseEventSubscribed = true; + } + } + private entitlementsUpdatedEventHandler = (payload: Object) => { const entitlements = Mapper.convertEntitlements(payload as Record); + this.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements); } + private deferredPurchaseCompletedEventHandler = (payload: Object) => { + const transaction = Mapper.convertDeferredTransaction(payload as Record); + + if (transaction) { + this.deferredPurchasesListener?.onDeferredPurchaseCompleted(transaction); + } + } + private promoPurchaseReceivedEventHandler = (productId: string) => { const promoPurchaseExecutor = async () => { const entitlements = await RNQonversion.promoPurchase(productId); @@ -397,13 +428,15 @@ export default class QonversionInternal implements QonversionApi { } setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { - if (this.entitlementsUpdateListener == null) { - RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); - } - + this.subscribeToEntitlementsEvent(); this.entitlementsUpdateListener = listener; } + setDeferredPurchasesListener(listener: DeferredPurchasesListener) { + this.subscribeToDeferredPurchaseEvent(); + this.deferredPurchasesListener = listener; + } + setPromoPurchasesDelegate(delegate: PromoPurchasesListener) { if (!isIos()) { return; diff --git a/src/internal/__tests__/QonversionInternal.test.ts b/src/internal/__tests__/QonversionInternal.test.ts new file mode 100644 index 0000000..e2be128 --- /dev/null +++ b/src/internal/__tests__/QonversionInternal.test.ts @@ -0,0 +1,253 @@ +import type { EntitlementsUpdateListener } from '../../dto/EntitlementsUpdateListener'; +import type { DeferredPurchasesListener } from '../../dto/DeferredPurchasesListener'; +import type { QEntitlement } from '../Mapper'; +import DeferredTransaction from '../../dto/DeferredTransaction'; + +// Capture event handlers registered on the native module mock +const eventHandlers: Record = {}; + +function fireEvent(name: string, payload: unknown) { + const handler = eventHandlers[name]; + if (!handler) { + throw new Error(`No handler registered for event "${name}"`); + } + handler(payload); +} + +jest.mock('../specs/NativeQonversionModule', () => ({ + __esModule: true, + default: { + storeSDKInfo: jest.fn(), + initializeSdk: jest.fn(), + onEntitlementsUpdated: jest.fn((handler: Function) => { + eventHandlers['onEntitlementsUpdated'] = handler; + }), + onDeferredPurchaseCompleted: jest.fn((handler: Function) => { + eventHandlers['onDeferredPurchaseCompleted'] = handler; + }), + onPromoPurchaseReceived: jest.fn(), + purchaseWithResult: jest.fn(), + }, +})); + +jest.mock('../Mapper', () => { + const MockDeferredTransaction = require('../../dto/DeferredTransaction').default; + return { + __esModule: true, + default: { + convertEntitlements: jest.fn((payload: Record) => { + const map = new Map(); + for (const [key, value] of Object.entries(payload)) { + map.set(key, value); + } + return map; + }), + convertDeferredTransaction: jest.fn((payload: Record) => { + if (!payload) return null; + return new MockDeferredTransaction( + payload.productId ?? '', + payload.transactionId ?? null, + payload.originalTransactionId ?? null, + payload.type ?? 'Unknown', + payload.value ?? 0, + payload.currency ?? null + ); + }), + convertPurchaseResult: jest.fn(), + }, + }; +}); + +import QonversionInternal from '../QonversionInternal'; +import QonversionConfig from '../../QonversionConfig'; +import { Environment, EntitlementsCacheLifetime, LaunchMode } from '../../dto/enums'; +import RNQonversion from '../specs/NativeQonversionModule'; + +function createConfig() { + return new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + undefined, + undefined, + false, + ); +} + +const sampleTransaction = { + productId: 'premium_product', + transactionId: 'txn_123', + originalTransactionId: 'txn_001', + type: 'Subscription', + value: 9.99, + currency: 'USD', +}; + +const entitlementsPayload: Record = { + premium: { id: 'premium', productId: 'premium_product', isActive: true } as unknown as QEntitlement, +}; + +describe('QonversionInternal - DeferredPurchasesListener (native event)', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('subscribes to native onDeferredPurchaseCompleted event', () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + instance.setDeferredPurchasesListener(listener); + + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalled(); + }); + + it('fires listener with DeferredTransaction when native event received', () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener); + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); + + expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + const arg = (listener.onDeferredPurchaseCompleted as jest.Mock).mock.calls[0][0]; + expect(arg).toBeInstanceOf(DeferredTransaction); + expect(arg.productId).toBe('premium_product'); + expect(arg.transactionId).toBe('txn_123'); + expect(arg.type).toBe('Subscription'); + expect(arg.value).toBe(9.99); + expect(arg.currency).toBe('USD'); + }); + + it('does not fire listener when no listener is set', () => { + void new QonversionInternal(createConfig()); + + // No listener set, event handler not registered + expect(eventHandlers['onDeferredPurchaseCompleted']).toBeUndefined(); + }); + + it('replaces previous listener when setting a new one', () => { + const instance = new QonversionInternal(createConfig()); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); + + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); + + expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('subscribes to onDeferredPurchaseCompleted only once', () => { + const instance = new QonversionInternal(createConfig()); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); + + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('deferred listener does NOT subscribe to onEntitlementsUpdated', () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener); + + expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); + }); +}); + +describe('QonversionInternal - deprecated setEntitlementsUpdateListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('subscribes to onEntitlementsUpdated', () => { + const instance = new QonversionInternal(createConfig()); + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + + instance.setEntitlementsUpdateListener(oldListener); + + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalled(); + }); + + it('fires for ALL entitlement updates', () => { + const instance = new QonversionInternal(createConfig()); + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + + instance.setEntitlementsUpdateListener(oldListener); + fireEvent('onEntitlementsUpdated', entitlementsPayload); + + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + }); +}); + +describe('QonversionInternal - both listeners coexist', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('both listeners fire independently from their own native events', () => { + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + oldListener, + undefined, + false, + newListener, + ); + + void new QonversionInternal(config); + + // Entitlements update fires old listener only + fireEvent('onEntitlementsUpdated', entitlementsPayload); + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(newListener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + + // Deferred purchase fires new listener only + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); // still 1 + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('subscribes to both native events independently', () => { + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + oldListener, + undefined, + false, + newListener, + ); + + void new QonversionInternal(config); + + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/internal/specs/NativeQonversionModule.ts b/src/internal/specs/NativeQonversionModule.ts index f757404..375d1ae 100644 --- a/src/internal/specs/NativeQonversionModule.ts +++ b/src/internal/specs/NativeQonversionModule.ts @@ -78,6 +78,7 @@ export interface Spec extends TurboModule { detachUserFromRemoteConfiguration(remoteConfigurationId: string): Promise; readonly onEntitlementsUpdated: EventEmitter; // Record + readonly onDeferredPurchaseCompleted: EventEmitter; // Record readonly onPromoPurchaseReceived: EventEmitter; }