Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.
Merged
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
9 changes: 9 additions & 0 deletions ios/HybridRnIap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class HybridRnIap: HybridRnIapSpec {
private var deliveredPurchaseEventKeys: Set<String> = []
private var deliveredPurchaseEventOrder: [String] = []
private let purchaseEventDedupLimit = 128
private static let duplicatePurchaseCode = "duplicate-purchase"
private var purchasePayloadById: [String: [String: Any]] = [:]
// Thread safety lock for listener arrays and error dedup state
private let listenerLock = NSLock()
Expand Down Expand Up @@ -1042,6 +1043,14 @@ class HybridRnIap: HybridRnIapSpec {

if isDuplicate {
RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)")
let error = NitroPurchaseResult(
responseCode: -1,
debugMessage: nil,
code: HybridRnIap.duplicatePurchaseCode,
message: "Duplicate purchase update skipped for \(purchase.productId). Use restorePurchases or getAvailablePurchases to recover.",
purchaseToken: nil
)
sendPurchaseError(error, productId: purchase.productId)
return
}

Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/utils/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
parseErrorStringToJsonObj,
isUserCancelledError,
isDuplicatePurchaseError,
DUPLICATE_PURCHASE_CODE,
} from '../../utils/error';
import {ErrorCode} from '../../types';

Expand Down Expand Up @@ -140,4 +142,43 @@ describe('Error utilities', () => {
expect(isUserCancelledError(error)).toBe(false);
});
});

describe('isDuplicatePurchaseError', () => {
it('should return true for duplicate purchase error', () => {
const error = {
code: DUPLICATE_PURCHASE_CODE,
message: 'Duplicate purchase update skipped',
};

expect(isDuplicatePurchaseError(error)).toBe(true);
});

it('should return true for duplicate-purchase string code', () => {
const error = {
code: 'duplicate-purchase',
message: 'Duplicate',
};

expect(isDuplicatePurchaseError(error)).toBe(true);
});

it('should return false for other errors', () => {
const error = {
code: ErrorCode.UserCancelled,
message: 'User cancelled',
};

expect(isDuplicatePurchaseError(error)).toBe(false);
});

it('should return false for undefined', () => {
expect(isDuplicatePurchaseError(undefined)).toBe(false);
});
});

describe('DUPLICATE_PURCHASE_CODE', () => {
it('should equal duplicate-purchase', () => {
expect(DUPLICATE_PURCHASE_CODE).toBe('duplicate-purchase');
});
});
});
43 changes: 43 additions & 0 deletions src/__tests__/utils/errorMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
getUserFriendlyErrorMessage,
isRecoverableError,
isUserCancelledError,
isDuplicatePurchaseError,
DUPLICATE_PURCHASE_CODE,
} from '../../utils/errorMapping';
import {ErrorCode} from '../../types';

Expand Down Expand Up @@ -45,6 +47,47 @@ describe('utils/errorMapping', () => {
).toBe(false);
});

test('isDuplicatePurchaseError detects duplicate purchase errors', () => {
expect(
isDuplicatePurchaseError({
code: DUPLICATE_PURCHASE_CODE,
message: 'x',
} as any),
).toBe(true);
expect(
isDuplicatePurchaseError({
code: 'duplicate-purchase',
message: 'x',
} as any),
).toBe(true);
expect(
isDuplicatePurchaseError({
code: ErrorCode.UserCancelled,
message: 'x',
} as any),
).toBe(false);
});

test('isRecoverableError includes duplicate-purchase', () => {
expect(
isRecoverableError({
code: DUPLICATE_PURCHASE_CODE,
message: 'x',
} as any),
).toBe(true);
});

test('getUserFriendlyErrorMessage returns message for duplicate-purchase', () => {
expect(
getUserFriendlyErrorMessage({
code: DUPLICATE_PURCHASE_CODE,
message: 'ignored',
} as any),
).toBe(
'This purchase has already been processed. Try restoring purchases.',
);
});

test('getUserFriendlyErrorMessage maps known codes and falls back to message', () => {
expect(
getUserFriendlyErrorMessage({
Expand Down
19 changes: 19 additions & 0 deletions src/utils/__tests__/errorMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
createPurchaseErrorFromPlatform,
ErrorCodeUtils,
isUserCancelledError,
isDuplicatePurchaseError,
isNetworkError,
isRecoverableError,
getUserFriendlyErrorMessage,
ErrorCodeMapping,
DUPLICATE_PURCHASE_CODE,
} from '../errorMapping';
import {ErrorCode} from '../../types';

Expand Down Expand Up @@ -150,6 +152,19 @@ describe('errorMapping', () => {
});
});

describe('isDuplicatePurchaseError', () => {
it('should identify duplicate purchase errors', () => {
expect(isDuplicatePurchaseError({code: DUPLICATE_PURCHASE_CODE})).toBe(
true,
);
expect(isDuplicatePurchaseError({code: 'duplicate-purchase'})).toBe(true);
expect(isDuplicatePurchaseError({code: ErrorCode.UserCancelled})).toBe(
false,
);
expect(isDuplicatePurchaseError({})).toBe(false);
});
});

describe('isNetworkError', () => {
it('should identify network-related errors', () => {
expect(isNetworkError({code: ErrorCode.NetworkError})).toBe(true);
Expand All @@ -175,6 +190,7 @@ describe('errorMapping', () => {
);
expect(isRecoverableError({code: ErrorCode.QueryProduct})).toBe(true);
expect(isRecoverableError({code: ErrorCode.InitConnection})).toBe(true);
expect(isRecoverableError({code: DUPLICATE_PURCHASE_CODE})).toBe(true);
expect(isRecoverableError({code: ErrorCode.UserCancelled})).toBe(false);
});
});
Expand All @@ -193,6 +209,9 @@ describe('errorMapping', () => {
expect(getUserFriendlyErrorMessage({code: ErrorCode.SkuNotFound})).toBe(
'Requested product could not be found',
);
expect(getUserFriendlyErrorMessage({code: DUPLICATE_PURCHASE_CODE})).toBe(
'This purchase has already been processed. Try restoring purchases.',
);
});

it('should return custom message when provided', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,9 @@ export function isUserCancelledError(
errorObj.responseCode === 1
); // Android BillingClient.BillingResponseCode.USER_CANCELED
}

// Re-export from errorMapping for public API convenience
export {
isDuplicatePurchaseError,
DUPLICATE_PURCHASE_CODE,
} from './errorMapping';
14 changes: 14 additions & 0 deletions src/utils/errorMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

import {ErrorCode, type IapPlatform} from '../types';

/**
* Error code for duplicate purchase events detected on iOS.
* Defined here because it originates from react-native-iap's dedup logic,
* not from the OpenIAP upstream that generates src/types.ts.
*/
export const DUPLICATE_PURCHASE_CODE = 'duplicate-purchase' as const;

const ERROR_CODE_ALIASES: Record<string, ErrorCode> = {
E_USER_CANCELED: ErrorCode.UserCancelled,
USER_CANCELED: ErrorCode.UserCancelled,
Expand Down Expand Up @@ -271,6 +278,10 @@ export function isUserCancelledError(error: unknown): boolean {
return extractCode(error) === ErrorCode.UserCancelled;
}

export function isDuplicatePurchaseError(error: unknown): boolean {
return extractCode(error) === DUPLICATE_PURCHASE_CODE;
}

export function isNetworkError(error: unknown): boolean {
const networkErrors: ErrorCode[] = [
ErrorCode.NetworkError,
Expand All @@ -296,6 +307,7 @@ export function isRecoverableError(error: unknown): boolean {
ErrorCode.InitConnection,
ErrorCode.SyncError,
ErrorCode.ConnectionClosed,
DUPLICATE_PURCHASE_CODE,
];

const code = extractCode(error);
Expand Down Expand Up @@ -326,6 +338,8 @@ export function getUserFriendlyErrorMessage(error: ErrorLike): string {
return 'Selected offer does not match the SKU';
case ErrorCode.DeferredPayment:
return 'Payment is pending approval';
case DUPLICATE_PURCHASE_CODE:
return 'This purchase has already been processed. Try restoring purchases.';
case ErrorCode.NotPrepared:
return 'In-app purchase is not ready. Please try again later.';
case ErrorCode.ServiceError:
Expand Down
Loading