diff --git a/__tests__/index.test.js b/__tests__/index.test.js index c6ffe27aa..d967ff625 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2177,6 +2177,82 @@ describe("Purchases", () => { }); + describe("generateRewardVerificationToken", () => { + describe("when Purchases is not configured", () => { + it("rejects", async () => { + NativeModules.RNPurchases.isConfigured.mockResolvedValueOnce(false); + + try { + await Purchases.generateRewardVerificationToken("impression_1"); + fail("expected error"); + } catch (error) {} + + expect(NativeModules.RNPurchases.generateRewardVerificationToken).toBeCalledTimes(0); + }); + }); + + it("passes the impression id and returns the token", async () => { + const token = { + customData: "custom_data", + clientTransactionId: "client_transaction_1", + appUserID: "app_user_1", + }; + NativeModules.RNPurchases.generateRewardVerificationToken.mockResolvedValueOnce(token); + + const result = await Purchases.generateRewardVerificationToken("impression_1"); + + expect(NativeModules.RNPurchases.generateRewardVerificationToken).toBeCalledTimes(1); + expect(NativeModules.RNPurchases.generateRewardVerificationToken).toBeCalledWith("impression_1"); + expect(result).toEqual(token); + }); + }); + + describe("pollRewardVerification", () => { + describe("when Purchases is not configured", () => { + it("rejects", async () => { + NativeModules.RNPurchases.isConfigured.mockResolvedValueOnce(false); + + try { + await Purchases.pollRewardVerification("client_transaction_1"); + fail("expected error"); + } catch (error) {} + + expect(NativeModules.RNPurchases.pollRewardVerification).toBeCalledTimes(0); + }); + }); + + it("passes the transaction id and returns the verification result", async () => { + const verificationResult = { + failed: false, + reward: { type: "virtual_currency", code: "GOLD", amount: 100 }, + moreRewards: [ + { + type: "entitlement", + identifier: "premium", + expiresAt: "2026-07-01T00:00:00Z", + expiresAtMillis: 1782518400000, + }, + ], + }; + NativeModules.RNPurchases.pollRewardVerification.mockResolvedValueOnce(verificationResult); + + const result = await Purchases.pollRewardVerification("client_transaction_1"); + + expect(NativeModules.RNPurchases.pollRewardVerification).toBeCalledTimes(1); + expect(NativeModules.RNPurchases.pollRewardVerification).toBeCalledWith("client_transaction_1"); + expect(result).toEqual(verificationResult); + }); + + it("returns a failed result without rejecting", async () => { + const verificationResult = { failed: true, moreRewards: [] }; + NativeModules.RNPurchases.pollRewardVerification.mockResolvedValueOnce(verificationResult); + + const result = await Purchases.pollRewardVerification("client_transaction_1"); + + expect(result).toEqual(verificationResult); + }); + }); + describe("adTracker", () => { const requiredDisplayedData = { mediatorName: "AdMob", diff --git a/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java b/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java index 738a75162..005bf670a 100644 --- a/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java +++ b/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java @@ -655,6 +655,16 @@ public void trackAdFailedToLoad(ReadableMap data) { CommonKt.trackAdFailedToLoad(data.toHashMap()); } + @ReactMethod + public void generateRewardVerificationToken(String impressionId, final Promise promise) { + promise.resolve(convertMapToWriteableMap(CommonKt.generateRewardVerificationToken(impressionId))); + } + + @ReactMethod + public void pollRewardVerification(String clientTransactionId, final Promise promise) { + CommonKt.pollRewardVerification(clientTransactionId, getOnResult(promise)); + } + // endregion //================================================================================ diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 9179f1006..559ac2ea7 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -703,6 +703,20 @@ static void logUnavailablePresentCodeRedemptionSheet() { } } +RCT_EXPORT_METHOD(generateRewardVerificationToken:(NSString *)impressionId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + resolve([RCCommonFunctionality generateRewardVerificationTokenWithImpressionId:impressionId]); +} + +RCT_EXPORT_METHOD(pollRewardVerification:(NSString *)clientTransactionId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + [RCCommonFunctionality pollRewardVerificationWithClientTransactionId:clientTransactionId + completion:[self getResponseCompletionBlockWithResolve:resolve + reject:reject]]; +} + #pragma mark - #pragma mark Delegate Methods - (void)purchases:(RCPurchases *)purchases receivedUpdatedCustomerInfo:(RCCustomerInfo *)customerInfo { diff --git a/scripts/setupJest.js b/scripts/setupJest.js index 8f79a2f6b..f90000b18 100644 --- a/scripts/setupJest.js +++ b/scripts/setupJest.js @@ -816,7 +816,9 @@ NativeModules.RNPurchases = { trackAdOpened: jest.fn(), trackAdLoaded: jest.fn(), trackAdRevenue: jest.fn(), - trackAdFailedToLoad: jest.fn() + trackAdFailedToLoad: jest.fn(), + generateRewardVerificationToken: jest.fn(), + pollRewardVerification: jest.fn() }; jest.mock( diff --git a/src/browser/nativeModule.ts b/src/browser/nativeModule.ts index 74fb432a7..c014dbeb2 100644 --- a/src/browser/nativeModule.ts +++ b/src/browser/nativeModule.ts @@ -360,4 +360,10 @@ export const browserNativeModuleRNPurchases = { trackAdFailedToLoad: async (_data: any) => { methodNotSupportedOnWeb('trackAdFailedToLoad'); }, + generateRewardVerificationToken: async (_impressionId: string) => { + methodNotSupportedOnWeb('generateRewardVerificationToken'); + }, + pollRewardVerification: async (_clientTransactionId: string) => { + methodNotSupportedOnWeb('pollRewardVerification'); + }, }; diff --git a/src/purchases.ts b/src/purchases.ts index 5357792c0..ad6b5f4de 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -278,6 +278,68 @@ export interface AdFailedToLoadData { placement?: string | null; } +/** + * Token generated for a rewarded ad impression. Pass `clientTransactionId` to + * the ad network as server-side verification custom data, then to + * {@link Purchases.pollRewardVerification} to await the reward. + * @beta + */ +export interface RewardVerificationToken { + customData: string; + clientTransactionId: string; + appUserID: string; +} + +/** + * A reward granted after a verified rewarded ad. Discriminated by `type`. + * @beta + */ +export type VerifiedReward = + | VerifiedVirtualCurrencyReward + | VerifiedEntitlementReward + | VerifiedNoReward + | VerifiedUnsupportedReward; + +/** @beta */ +export interface VerifiedVirtualCurrencyReward { + type: "virtual_currency"; + code: string; + amount: number; +} + +/** @beta */ +export interface VerifiedEntitlementReward { + type: "entitlement"; + identifier: string; + /** ISO 8601 expiration date string. */ + expiresAt: string; + /** Expiration date in milliseconds since epoch. */ + expiresAtMillis: number; +} + +/** Verification completed but nothing was granted. @beta */ +export interface VerifiedNoReward { + type: "no_reward"; +} + +/** Verification completed but the reward type isn't modeled by this SDK version. @beta */ +export interface VerifiedUnsupportedReward { + type: "unsupported_reward"; +} + +/** + * Result of polling for reward verification. + * @beta + */ +export interface RewardVerificationResult { + /** The primary reward when verification succeeded; absent on failure. */ + reward?: VerifiedReward; + /** Additional rewards granted alongside the primary; never repeats it; empty on failure. */ + moreRewards: VerifiedReward[]; + /** True when verification did not complete (rejected / timeout / network). */ + failed: boolean; +} + let debugEventListeners: DebugEventListener[] = []; eventEmitter?.addListener( @@ -2061,6 +2123,43 @@ export default class Purchases { }); } + /** + * Generates a reward verification token for a rewarded ad impression. + * + * Pass the returned `clientTransactionId` to the ad network as the + * server-side verification custom data. After the ad completes, pass the same + * id to {@link Purchases.pollRewardVerification} to await the reward. + * + * @param impressionId - The impression identifier of the rewarded ad. + * @returns {Promise} promise with the generated token. + * @beta + */ + public static async generateRewardVerificationToken( + impressionId: string + ): Promise { + await throwIfNotConfigured(); + return RNPurchases.generateRewardVerificationToken(impressionId); + } + + /** + * Polls RevenueCat for the reward verification result of a rewarded ad. + * + * The returned promise stays pending while the native poller runs (up to + * ~10-30s) and resolves once verification completes or times out. A timed-out + * or rejected verification resolves with `failed: true` rather than rejecting. + * + * @param clientTransactionId - The `clientTransactionId` from + * {@link Purchases.generateRewardVerificationToken}. + * @returns {Promise} promise with the verification result. + * @beta + */ + public static async pollRewardVerification( + clientTransactionId: string + ): Promise { + await throwIfNotConfigured(); + return RNPurchases.pollRewardVerification(clientTransactionId); + } + private static async throwIfAndroidPlatform() { if (Platform.OS === "android") { throw new UnsupportedPlatformError();