Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

//================================================================================
Expand Down
14 changes: 14 additions & 0 deletions ios/RNPurchases.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion scripts/setupJest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/browser/nativeModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
};
99 changes: 99 additions & 0 deletions src/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<RewardVerificationToken>} promise with the generated token.
* @beta
*/
public static async generateRewardVerificationToken(
impressionId: string
): Promise<RewardVerificationToken> {
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<RewardVerificationResult>} promise with the verification result.
* @beta
*/
public static async pollRewardVerification(
clientTransactionId: string
): Promise<RewardVerificationResult> {
await throwIfNotConfigured();
return RNPurchases.pollRewardVerification(clientTransactionId);
}

private static async throwIfAndroidPlatform() {
if (Platform.OS === "android") {
throw new UnsupportedPlatformError();
Expand Down