Skip to content

Commit 42d6f2b

Browse files
hyochanclaude
andauthored
fix: add onError callback to useIAP and pass subscriptionProductReplacementParams (#3128)
## Summary - Add `onError` callback to `UseIapOptions` for handling non-purchase errors (fetchProducts, getAvailablePurchases, getActiveSubscriptions, restorePurchases) - Fix `subscriptionProductReplacementParams` not being passed to native module in `requestPurchase` for Android subscription replacements - Add comprehensive tests for both fixes - Update documentation (use-iap.md, llms.txt, llms-full.txt) - Remove unused `shouldAutoSyncPurchases` from docs ## Test plan - [x] Run `yarn typecheck` - passes - [x] Run `yarn lint` - passes - [x] Run `yarn test` - all 187 tests pass - [x] Added tests for `onError` callback (7 new tests) - [x] Added tests for `subscriptionProductReplacementParams` (3 new tests) ## Related Issues Fixes related to expo-iap#303 and expo-iap#304 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an onError callback option to surface non-purchase operation errors (e.g., product fetches, availability checks). * Added support for Android subscription replacement parameters to enable custom upgrade/downgrade behavior in purchase requests. * **Documentation** * API docs updated with the new onError option, examples showing onError and purchase-error handling, and guidance for the Android subscription replacement parameter. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e7efa74 commit 42d6f2b

File tree

10 files changed

+335
-17
lines changed

10 files changed

+335
-17
lines changed

docs/docs/api/use-iap.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const {
6262
interface UseIAPOptions {
6363
onPurchaseSuccess?: (purchase: Purchase) => void;
6464
onPurchaseError?: (error: PurchaseError) => void;
65-
shouldAutoSyncPurchases?: boolean; // Controls auto sync behavior inside the hook
65+
onError?: (error: Error) => void; // Non-purchase errors (fetchProducts, getAvailablePurchases, etc.)
6666
onPromotedProductIOS?: (product: Product) => void; // iOS promoted products
6767
}
6868
```
@@ -96,6 +96,41 @@ interface UseIAPOptions {
9696
};
9797
```
9898

99+
#### onError
100+
101+
- **Type**: `(error: Error) => void`
102+
- **Description**: Called when non-purchase operations fail (e.g., `fetchProducts`, `getAvailablePurchases`, `getActiveSubscriptions`, `restorePurchases`). Use this callback to handle errors from query operations that are not related to the purchase flow itself.
103+
- **Example**:
104+
105+
```tsx
106+
onError: (error) => {
107+
// Handle non-purchase errors (network issues, query failures, etc.)
108+
console.error('IAP operation failed:', error.message);
109+
Alert.alert('Error', 'Failed to load products. Please check your connection.');
110+
};
111+
```
112+
113+
- **Full Example with Both Error Callbacks**:
114+
115+
```tsx
116+
const {fetchProducts, requestPurchase} = useIAP({
117+
onPurchaseSuccess: (purchase) => {
118+
console.log('Purchase successful:', purchase.productId);
119+
},
120+
onPurchaseError: (error) => {
121+
// Purchase-specific errors (user cancelled, payment failed, etc.)
122+
if (error.code !== ErrorCode.UserCancelled) {
123+
Alert.alert('Purchase Failed', error.message);
124+
}
125+
},
126+
onError: (error) => {
127+
// Non-purchase errors (fetchProducts failed, network issues, etc.)
128+
console.error('Operation failed:', error.message);
129+
Alert.alert('Error', 'Something went wrong. Please try again.');
130+
},
131+
});
132+
```
133+
99134
#### autoFinishTransactions
100135

101136
- **Type**: `boolean`

docs/static/llms-full.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ function useIAP(options?: UseIAPOptions): UseIAPReturn;
9595
interface UseIAPOptions {
9696
onPurchaseSuccess?: (purchase: Purchase) => void;
9797
onPurchaseError?: (error: PurchaseError) => void;
98-
shouldAutoSyncPurchases?: boolean;
98+
onError?: (error: Error) => void; // Non-purchase errors (fetchProducts, getAvailablePurchases, etc.)
9999
onPromotedProductIOS?: (product: Product) => void;
100100
}
101101
```
@@ -136,6 +136,7 @@ interface UseIAPReturn {
136136
- **Auto-connects**: Calls `initConnection()` on mount, `endConnection()` on unmount
137137
- **Void-returning methods**: `fetchProducts`, `requestPurchase`, `getAvailablePurchases` return `Promise<void>` and update internal state
138138
- **Use callbacks**: Always handle purchases via `onPurchaseSuccess` and `onPurchaseError`
139+
- **Error handling**: Use `onPurchaseError` for purchase-related errors and `onError` for non-purchase errors (fetchProducts, getAvailablePurchases, etc.)
139140

140141
### Basic Example
141142

@@ -170,6 +171,11 @@ function Store() {
170171
Alert.alert('Purchase Failed', error.message);
171172
}
172173
},
174+
onError: (error) => {
175+
// Handle non-purchase errors (fetchProducts, getAvailablePurchases, etc.)
176+
console.error('IAP operation failed:', error.message);
177+
Alert.alert('Error', 'Failed to load products. Please try again.');
178+
},
173179
});
174180

175181
useEffect(() => {

docs/static/llms.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ const purchases = await getAvailablePurchases(); // purchases is void!
108108

109109
**Options:**
110110
- `onPurchaseSuccess?: (purchase: Purchase) => void` - Success callback
111-
- `onPurchaseError?: (error: PurchaseError) => void` - Error callback
111+
- `onPurchaseError?: (error: PurchaseError) => void` - Purchase error callback
112+
- `onError?: (error: Error) => void` - Non-purchase error callback (fetchProducts, getAvailablePurchases, etc.)
112113

113114
### Direct API Functions
114115

docs/versioned_docs/version-14.4/api/use-iap.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const {
6262
interface UseIAPOptions {
6363
onPurchaseSuccess?: (purchase: Purchase) => void;
6464
onPurchaseError?: (error: PurchaseError) => void;
65-
shouldAutoSyncPurchases?: boolean; // Controls auto sync behavior inside the hook
6665
onPromotedProductIOS?: (product: Product) => void; // iOS promoted products
6766
}
6867
```

docs/versioned_docs/version-14.5/api/use-iap.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const {
6262
interface UseIAPOptions {
6363
onPurchaseSuccess?: (purchase: Purchase) => void;
6464
onPurchaseError?: (error: PurchaseError) => void;
65-
shouldAutoSyncPurchases?: boolean; // Controls auto sync behavior inside the hook
6665
onPromotedProductIOS?: (product: Product) => void; // iOS promoted products
6766
}
6867
```

docs/versioned_docs/version-14.6/api/use-iap.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const {
6262
interface UseIAPOptions {
6363
onPurchaseSuccess?: (purchase: Purchase) => void;
6464
onPurchaseError?: (error: PurchaseError) => void;
65-
shouldAutoSyncPurchases?: boolean; // Controls auto sync behavior inside the hook
6665
onPromotedProductIOS?: (product: Product) => void; // iOS promoted products
6766
}
6867
```

src/__tests__/hooks/useIAP.test.ts

Lines changed: 189 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,30 @@ describe('hooks/useIAP (renderer)', () => {
4040
});
4141

4242
let capturedPurchaseListener: any;
43+
let mockFetchProducts: jest.SpyInstance;
44+
let mockGetAvailablePurchases: jest.SpyInstance;
45+
let mockGetActiveSubscriptions: jest.SpyInstance;
46+
let mockHasActiveSubscriptions: jest.SpyInstance;
47+
let mockRestorePurchases: jest.SpyInstance;
4348

4449
beforeEach(() => {
4550
jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any);
46-
jest.spyOn(IAP, 'getAvailablePurchases').mockResolvedValue([] as any);
47-
jest.spyOn(IAP, 'getActiveSubscriptions').mockResolvedValue([] as any);
48-
jest.spyOn(IAP, 'hasActiveSubscriptions').mockResolvedValue(false as any);
51+
mockGetAvailablePurchases = jest
52+
.spyOn(IAP, 'getAvailablePurchases')
53+
.mockResolvedValue([] as any);
54+
mockGetActiveSubscriptions = jest
55+
.spyOn(IAP, 'getActiveSubscriptions')
56+
.mockResolvedValue([] as any);
57+
mockHasActiveSubscriptions = jest
58+
.spyOn(IAP, 'hasActiveSubscriptions')
59+
.mockResolvedValue(false as any);
4960
jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined as any);
61+
mockFetchProducts = jest
62+
.spyOn(IAP, 'fetchProducts')
63+
.mockResolvedValue([] as any);
64+
mockRestorePurchases = jest
65+
.spyOn(IAP, 'restorePurchases')
66+
.mockResolvedValue(undefined as any);
5067
jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((cb: any) => {
5168
capturedPurchaseListener = cb;
5269
return {remove: jest.fn()};
@@ -100,4 +117,173 @@ describe('hooks/useIAP (renderer)', () => {
100117
});
101118
expect(IAP.finishTransaction).toBeDefined();
102119
});
120+
121+
describe('onError callback', () => {
122+
it('calls onError when fetchProducts fails', async () => {
123+
const fetchError = new Error('Network error fetching products');
124+
mockFetchProducts.mockRejectedValueOnce(fetchError);
125+
126+
let api: any;
127+
const onError = jest.fn();
128+
const Harness = () => {
129+
api = useIAP({onError});
130+
return null;
131+
};
132+
133+
await act(async () => {
134+
TestRenderer.create(React.createElement(Harness));
135+
});
136+
await act(async () => {});
137+
138+
// Call fetchProducts which should trigger onError
139+
await act(async () => {
140+
await api.fetchProducts({skus: ['product1']});
141+
});
142+
143+
expect(onError).toHaveBeenCalledWith(fetchError);
144+
});
145+
146+
it('calls onError when getAvailablePurchases fails', async () => {
147+
const purchaseError = new Error('Failed to get purchases');
148+
mockGetAvailablePurchases.mockRejectedValueOnce(purchaseError);
149+
150+
let api: any;
151+
const onError = jest.fn();
152+
const Harness = () => {
153+
api = useIAP({onError});
154+
return null;
155+
};
156+
157+
await act(async () => {
158+
TestRenderer.create(React.createElement(Harness));
159+
});
160+
await act(async () => {});
161+
162+
await act(async () => {
163+
await api.getAvailablePurchases();
164+
});
165+
166+
expect(onError).toHaveBeenCalledWith(purchaseError);
167+
});
168+
169+
it('calls onError when getActiveSubscriptions fails', async () => {
170+
const subscriptionError = new Error('Failed to get subscriptions');
171+
mockGetActiveSubscriptions.mockRejectedValueOnce(subscriptionError);
172+
173+
let api: any;
174+
const onError = jest.fn();
175+
const Harness = () => {
176+
api = useIAP({onError});
177+
return null;
178+
};
179+
180+
await act(async () => {
181+
TestRenderer.create(React.createElement(Harness));
182+
});
183+
await act(async () => {});
184+
185+
await act(async () => {
186+
const result = await api.getActiveSubscriptions();
187+
// Should return empty array on error
188+
expect(result).toEqual([]);
189+
});
190+
191+
expect(onError).toHaveBeenCalledWith(subscriptionError);
192+
});
193+
194+
it('calls onError when hasActiveSubscriptions fails', async () => {
195+
const hasSubsError = new Error('Failed to check subscriptions');
196+
mockHasActiveSubscriptions.mockRejectedValueOnce(hasSubsError);
197+
198+
let api: any;
199+
const onError = jest.fn();
200+
const Harness = () => {
201+
api = useIAP({onError});
202+
return null;
203+
};
204+
205+
await act(async () => {
206+
TestRenderer.create(React.createElement(Harness));
207+
});
208+
await act(async () => {});
209+
210+
await act(async () => {
211+
const result = await api.hasActiveSubscriptions();
212+
// Should return false on error
213+
expect(result).toBe(false);
214+
});
215+
216+
expect(onError).toHaveBeenCalledWith(hasSubsError);
217+
});
218+
219+
it('calls onError when restorePurchases fails', async () => {
220+
const restoreError = new Error('Failed to restore');
221+
mockRestorePurchases.mockRejectedValueOnce(restoreError);
222+
223+
let api: any;
224+
const onError = jest.fn();
225+
const Harness = () => {
226+
api = useIAP({onError});
227+
return null;
228+
};
229+
230+
await act(async () => {
231+
TestRenderer.create(React.createElement(Harness));
232+
});
233+
await act(async () => {});
234+
235+
await act(async () => {
236+
await api.restorePurchases();
237+
});
238+
239+
expect(onError).toHaveBeenCalledWith(restoreError);
240+
});
241+
242+
it('does not call onError when operations succeed', async () => {
243+
let api: any;
244+
const onError = jest.fn();
245+
const Harness = () => {
246+
api = useIAP({onError});
247+
return null;
248+
};
249+
250+
await act(async () => {
251+
TestRenderer.create(React.createElement(Harness));
252+
});
253+
await act(async () => {});
254+
255+
await act(async () => {
256+
await api.fetchProducts({skus: ['product1']});
257+
await api.getAvailablePurchases();
258+
await api.getActiveSubscriptions();
259+
await api.hasActiveSubscriptions();
260+
});
261+
262+
expect(onError).not.toHaveBeenCalled();
263+
});
264+
265+
it('converts non-Error objects to Error in onError callback', async () => {
266+
const stringError = 'String error message';
267+
mockFetchProducts.mockRejectedValueOnce(stringError);
268+
269+
let api: any;
270+
const onError = jest.fn();
271+
const Harness = () => {
272+
api = useIAP({onError});
273+
return null;
274+
};
275+
276+
await act(async () => {
277+
TestRenderer.create(React.createElement(Harness));
278+
});
279+
await act(async () => {});
280+
281+
await act(async () => {
282+
await api.fetchProducts({skus: ['product1']});
283+
});
284+
285+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
286+
expect(onError.mock.calls[0][0].message).toBe(stringError);
287+
});
288+
});
103289
});

0 commit comments

Comments
 (0)