expo-iap 2.4.4 → 2.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## Overview
6
6
 
7
- `expo-iap` is an Expo module for handling in-app purchases (IAP) on iOS (StoreKit 2) and Android (Google Play Billing). It supports consumables, non-consumables, and subscriptions. Unlike [`react-native-iap`](https://github.com/hyochan/react-native-iap), which requires native setup, `expo-iap` integrates seamlessly into Expo's [managed workflow](https://docs.expo.dev/archive/managed-vs-bare)—no ejecting needed! However, you’ll need a [development client](https://docs.expo.dev/development/introduction/) instead of Expo Go for full functionality. Starting from version 2.2.8, most features of `react-native-iap` have been ported.
7
+ `expo-iap` is an Expo module for handling in-app purchases (IAP) on iOS (StoreKit 2) and Android (Google Play Billing). It supports consumables, non-consumables, and subscriptions. Unlike [`react-native-iap`](https://github.com/hyochan/react-native-iap), which requires native setup, `expo-iap` integrates seamlessly into Expo's managed workflow! However, you’ll need a [development client](https://docs.expo.dev/development/introduction/) instead of Expo Go for full functionality. Starting from version 2.2.8, most features of `react-native-iap` have been ported.
8
8
 
9
9
  ## Installation
10
10
 
11
- `expo-iap` is compatible with [Expo SDK](https://expo.dev) 51+ and supports both managed workflows and React Native CLI projects. Official documentation is in progress for SDK inclusion (see [Expo IAP Documentation](link-to-docs)).
11
+ `expo-iap` is compatible with [Expo SDK](https://expo.dev) 51+ and supports both managed workflows and React Native CLI projects. Official documentation is in progress for SDK inclusion (see [Expo IAP Documentation](https://github.com/hyochan/expo-iap/blob/main/docs/IAP.md#expo-iap-documentation)).
12
12
 
13
13
  ### Add the Package
14
14
 
@@ -262,43 +262,49 @@ export default function SimpleIAP() {
262
262
  }
263
263
  ```
264
264
 
265
- ## Using useIAP Hook
265
+ ## Using `useIAP` Hook
266
266
 
267
- The `useIAP` hook simplifies managing in-app purchases. Below is an example updated to use the new `requestPurchase` signature:
267
+ The `useIAP` hook from `expo-iap` lets you manage in-app purchases in functional React components. This example reflects a **real-world production use case** with:
268
+
269
+ - Consumable & subscription support
270
+ - Purchase lifecycle management
271
+ - Platform-specific `requestPurchase` formats
272
+ - UX alerts and loading states
273
+ - Optional receipt validation before finishing the transaction
274
+
275
+ ### 🧭 Flow Overview
276
+
277
+ | Step | Description |
278
+ | --- | --- |
279
+ | 1️⃣ | Wait for `connected === true` before fetching products and subscriptions |
280
+ | 2️⃣ | Render UI with products/subscriptions dynamically from store |
281
+ | 3️⃣ | Trigger purchases via `requestPurchase()` (with Android/iOS handling) |
282
+ | 4️⃣ | When `currentPurchase` updates, validate & finish the transaction |
283
+ | 5️⃣ | Handle `currentPurchaseError` for graceful UX |
284
+
285
+ ### ✅ Realistic Example with `useIAP`
268
286
 
269
287
  ```tsx
270
- import {useEffect, useState} from 'react';
288
+ import {useEffect, useState, useCallback} from 'react';
271
289
  import {
272
- SafeAreaView,
273
- ScrollView,
274
- StyleSheet,
275
- Text,
276
290
  View,
277
- Pressable,
291
+ Text,
292
+ ScrollView,
278
293
  Button,
279
- InteractionManager,
280
294
  Alert,
295
+ Platform,
296
+ InteractionManager,
281
297
  } from 'react-native';
282
298
  import {useIAP} from 'expo-iap';
283
- import type {ProductPurchase, SubscriptionProduct} from 'expo-iap';
284
-
285
- // Define SKUs
286
- const productSkus = [
287
- 'cpk.points.1000',
288
- 'cpk.points.5000',
289
- 'cpk.points.10000',
290
- 'cpk.points.30000',
291
- ];
292
- const subscriptionSkus = [
293
- 'cpk.membership.monthly.bronze',
294
- 'cpk.membership.monthly.silver',
295
- ];
296
-
297
- // Define operations
298
- const operations = ['getProducts', 'getSubscriptions'] as const;
299
- type Operation = (typeof operations)[number];
300
-
301
- export default function IAPWithHook() {
299
+ import type {
300
+ ProductAndroid,
301
+ ProductPurchaseAndroid,
302
+ } from 'expo-iap/build/types/ExpoIapAndroid.types';
303
+
304
+ const productSkus = ['dev.hyo.luent.10bulbs', 'dev.hyo.luent.30bulbs'];
305
+ const subscriptionSkus = ['dev.hyo.luent.premium'];
306
+
307
+ export default function PurchaseScreen() {
302
308
  const {
303
309
  connected,
304
310
  products,
@@ -307,195 +313,188 @@ export default function IAPWithHook() {
307
313
  currentPurchaseError,
308
314
  getProducts,
309
315
  getSubscriptions,
310
- finishTransaction,
311
316
  requestPurchase,
317
+ finishTransaction,
318
+ validateReceipt,
312
319
  } = useIAP();
313
320
 
314
321
  const [isReady, setIsReady] = useState(false);
322
+ const [isLoading, setIsLoading] = useState(false);
315
323
 
316
- // Fetch products and subscriptions only when connected
324
+ // 1️⃣ Initialize products & subscriptions
317
325
  useEffect(() => {
318
326
  if (!connected) return;
319
327
 
320
- const initializeIAP = async () => {
328
+ const loadStoreItems = async () => {
321
329
  try {
322
- await Promise.all([
323
- getProducts(productSkus),
324
- getSubscriptions(subscriptionSkus),
325
- ]);
330
+ await getProducts(productSkus);
331
+ await getSubscriptions(subscriptionSkus);
326
332
  setIsReady(true);
327
- } catch (error) {
328
- console.error('Error initializing IAP:', error);
333
+ } catch (e) {
334
+ console.error('IAP init error:', e);
329
335
  }
330
336
  };
331
- initializeIAP();
332
- }, [connected, getProducts, getSubscriptions]);
333
337
 
334
- // Handle purchase updates and errors
338
+ loadStoreItems();
339
+ }, [connected]);
340
+
341
+ // 2️⃣ Purchase handler when currentPurchase updates
335
342
  useEffect(() => {
336
- if (currentPurchase) {
337
- InteractionManager.runAfterInteractions(async () => {
338
- try {
339
- await finishTransaction({
340
- purchase: currentPurchase,
341
- isConsumable: currentPurchase.productType === 'inapp',
343
+ if (!currentPurchase) return;
344
+
345
+ const handlePurchase = async () => {
346
+ try {
347
+ setIsLoading(true);
348
+
349
+ const productId = currentPurchase.id;
350
+ const isConsumable = productSkus.includes(productId);
351
+
352
+ // ✅ Optionally validate receipt before finishing
353
+ let isValid = true;
354
+ if (Platform.OS === 'ios') {
355
+ const result = await validateReceipt(productId);
356
+ isValid = result?.isValid ?? true;
357
+ } else if (Platform.OS === 'android') {
358
+ const token = (currentPurchase as ProductPurchaseAndroid)
359
+ .purchaseTokenAndroid;
360
+ const packageName = 'your.android.package.name';
361
+
362
+ const result = await validateReceipt(productId, {
363
+ productToken: token,
364
+ packageName,
365
+ isSub: subscriptionSkus.includes(productId),
342
366
  });
343
- Alert.alert('Purchase Successful', JSON.stringify(currentPurchase));
344
- } catch (error) {
345
- console.error('Error finishing transaction:', error);
346
- Alert.alert('Transaction Error', String(error));
367
+ isValid = result?.isValid ?? true;
347
368
  }
348
- });
349
- }
350
369
 
370
+ if (!isValid) {
371
+ Alert.alert('Invalid purchase', 'Receipt validation failed');
372
+ return;
373
+ }
374
+
375
+ // 🧾 Finish transaction (important!)
376
+ await finishTransaction({
377
+ purchase: currentPurchase,
378
+ isConsumable,
379
+ });
380
+
381
+ // ✅ Grant item or unlock feature
382
+ Alert.alert(
383
+ 'Thank you!',
384
+ isConsumable
385
+ ? 'Bulbs added to your account!'
386
+ : 'Premium subscription activated.',
387
+ );
388
+ } catch (err) {
389
+ console.error('Finish transaction error:', err);
390
+ } finally {
391
+ setIsLoading(false);
392
+ }
393
+ };
394
+
395
+ handlePurchase();
396
+ }, [currentPurchase]);
397
+
398
+ // 3️⃣ Error handling
399
+ useEffect(() => {
351
400
  if (currentPurchaseError) {
352
401
  InteractionManager.runAfterInteractions(() => {
353
- Alert.alert('Purchase Error', JSON.stringify(currentPurchaseError));
402
+ Alert.alert('Purchase error', currentPurchaseError.message);
354
403
  });
404
+ setIsLoading(false);
355
405
  }
356
- }, [currentPurchase, currentPurchaseError, finishTransaction]);
406
+ }, [currentPurchaseError]);
357
407
 
358
- // Handle operation buttons
359
- const handleOperation = async (operation: Operation) => {
360
- if (!connected) {
361
- Alert.alert('Not Connected', 'Please wait for IAP to connect.');
362
- return;
363
- }
408
+ // 4️⃣ Purchase trigger
409
+ const handleBuy = useCallback(
410
+ async (productId: string, type?: 'subs') => {
411
+ try {
412
+ setIsLoading(true);
364
413
 
365
- try {
366
- switch (operation) {
367
- case 'getProducts':
368
- await getProducts(productSkus);
369
- break;
370
- case 'getSubscriptions':
371
- await getSubscriptions(subscriptionSkus);
372
- break;
414
+ if (Platform.OS === 'ios') {
415
+ await requestPurchase({
416
+ request: {sku: productId},
417
+ type,
418
+ });
419
+ } else {
420
+ const request: any = {skus: [productId]};
421
+
422
+ if (type === 'subs') {
423
+ const sub = subscriptions.find(
424
+ (s) => s.id === productId,
425
+ ) as ProductAndroid;
426
+ const offers =
427
+ sub?.subscriptionOfferDetails?.map((offer) => ({
428
+ sku: productId,
429
+ offerToken: offer.offerToken,
430
+ })) || [];
431
+
432
+ request.subscriptionOffers = offers;
433
+ }
434
+
435
+ await requestPurchase({request, type});
436
+ }
437
+ } catch (err) {
438
+ console.error('Purchase request failed:', err);
439
+ Alert.alert(
440
+ 'Error',
441
+ err instanceof Error ? err.message : 'Purchase failed',
442
+ );
443
+ setIsLoading(false);
373
444
  }
374
- } catch (error) {
375
- console.error(`Error in ${operation}:`, error);
376
- }
377
- };
445
+ },
446
+ [subscriptions],
447
+ );
378
448
 
379
- if (!connected) {
380
- return (
381
- <SafeAreaView style={styles.container}>
382
- <Text style={styles.title}>Connecting to IAP...</Text>
383
- </SafeAreaView>
384
- );
385
- }
449
+ if (!connected) return <Text>Connecting to store...</Text>;
450
+ if (!isReady) return <Text>Loading products...</Text>;
386
451
 
387
452
  return (
388
- <SafeAreaView style={styles.container}>
389
- <Text style={styles.title}>Expo IAP with useIAP Hook</Text>
390
- <View style={styles.buttons}>
391
- <ScrollView contentContainerStyle={styles.buttonsWrapper} horizontal>
392
- {operations.map((operation) => (
393
- <Pressable
394
- key={operation}
395
- onPress={() => handleOperation(operation)}
396
- >
397
- <View style={styles.buttonView}>
398
- <Text>{operation}</Text>
399
- </View>
400
- </Pressable>
401
- ))}
402
- </ScrollView>
403
- </View>
404
- <View style={styles.content}>
405
- {!isReady ? (
406
- <Text>Loading...</Text>
407
- ) : (
408
- <View style={{gap: 12}}>
409
- <Text style={{fontSize: 20}}>Products</Text>
410
- {products.map((item) => (
411
- <View key={item.id} style={{gap: 12}}>
412
- <Text>
413
- {item.title} -{' '}
414
- {item.platform === 'android'
415
- ? item.oneTimePurchaseOfferDetails?.formattedPrice
416
- : item.displayPrice}
417
- </Text>
418
- <Button
419
- title="Buy"
420
- onPress={() =>
421
- requestPurchase({
422
- request:
423
- item.platform === 'android'
424
- ? {skus: [item.id]}
425
- : {sku: item.id},
426
- })
427
- }
428
- />
429
- </View>
430
- ))}
431
-
432
- <Text style={{fontSize: 20}}>Subscriptions</Text>
433
- {subscriptions.map((item) => (
434
- <View key={item.id} style={{gap: 12}}>
435
- <Text>
436
- {item.title || item.displayName} -{' '}
437
- {item.platform === 'android' && item.subscriptionOfferDetails
438
- ? item.subscriptionOfferDetails[0]?.pricingPhases
439
- .pricingPhaseList[0].formattedPrice
440
- : item.displayPrice}
441
- </Text>
442
- <Button
443
- title="Subscribe"
444
- onPress={() =>
445
- requestPurchase({
446
- request:
447
- item.platform === 'android'
448
- ? {
449
- skus: [item.id],
450
- subscriptionOffers:
451
- item.subscriptionOfferDetails?.map((offer) => ({
452
- sku: item.id,
453
- offerToken: offer.offerToken,
454
- })) || [],
455
- }
456
- : {sku: item.id},
457
- type: 'subs',
458
- })
459
- }
460
- />
461
- </View>
462
- ))}
463
- </View>
464
- )}
465
- </View>
466
- </SafeAreaView>
453
+ <ScrollView contentContainerStyle={{padding: 20}}>
454
+ <Text style={{fontSize: 18, fontWeight: 'bold'}}>💡 Bulb Packs</Text>
455
+ {products.map((p) => (
456
+ <View key={p.id} style={{marginVertical: 10}}>
457
+ <Text>
458
+ {p.title} - {p.displayPrice}
459
+ </Text>
460
+ <Button
461
+ title={isLoading ? 'Processing...' : 'Buy'}
462
+ onPress={() => handleBuy(p.id)}
463
+ disabled={isLoading}
464
+ />
465
+ </View>
466
+ ))}
467
+
468
+ <Text style={{fontSize: 18, fontWeight: 'bold', marginTop: 30}}>
469
+ Subscription
470
+ </Text>
471
+ {subscriptions.map((s) => (
472
+ <View key={s.id} style={{marginVertical: 10}}>
473
+ <Text>
474
+ {s.title} - {s.displayPrice}
475
+ </Text>
476
+ <Button
477
+ title={isLoading ? 'Processing...' : 'Subscribe'}
478
+ onPress={() => handleBuy(s.id, 'subs')}
479
+ disabled={isLoading}
480
+ />
481
+ </View>
482
+ ))}
483
+ </ScrollView>
467
484
  );
468
485
  }
469
-
470
- const styles = StyleSheet.create({
471
- container: {
472
- flex: 1,
473
- backgroundColor: '#fff',
474
- alignItems: 'center',
475
- },
476
- title: {
477
- marginTop: 24,
478
- fontSize: 20,
479
- fontWeight: 'bold',
480
- },
481
- buttons: {
482
- height: 90,
483
- },
484
- buttonsWrapper: {
485
- padding: 24,
486
- gap: 8,
487
- },
488
- buttonView: {
489
- borderRadius: 8,
490
- borderWidth: 1,
491
- borderColor: '#000',
492
- padding: 8,
493
- },
494
- content: {
495
- flex: 1,
496
- alignSelf: 'stretch',
497
- padding: 24,
498
- gap: 12,
499
- },
500
- });
501
486
  ```
487
+
488
+ ---
489
+
490
+ 물론입니다. 아래는 보다 자연스럽고 요약된 느낌으로 다듬은 영어 버전입니다:
491
+
492
+ ---
493
+
494
+ ### 🔎 Key Benefits of This Approach
495
+
496
+ - ✅ Supports **both Android and iOS**, with platform-aware purchase handling
497
+ - ✅ Covers **both consumable items and subscriptions**
498
+ - ✅ Includes a **receipt validation flow** using `validateReceipt` (server-ready)
499
+ - ✅ Handles iOS cases where **auto-finishing transactions is disabled**
500
+ - ✅ Provides **user-friendly error and loading state management**
package/docs/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # Documentation
2
+
3
+ This directory contains detailed documentation for expo-iap.
4
+
5
+ ## Available Documents
6
+
7
+ - **[API Documentation](./IAP.md)** - Complete API reference and usage examples
8
+ - **[Error Code Management](./ERROR_CODES.md)** - Centralized error handling system documentation
9
+
10
+ ## Quick Links
11
+
12
+ - [Main README](../README.md) - Project overview and quick start
13
+ - [Example App](../example/) - Sample implementation
14
+ - [GitHub Issues](https://github.com/hyochan/expo-iap/issues) - Bug reports and feature requests
15
+
16
+ ## Contributing
17
+
18
+ If you find any documentation errors or have suggestions for improvements, please:
19
+
20
+ 1. Check existing [issues](https://github.com/hyochan/expo-iap/issues)
21
+ 2. Create a new issue with the `documentation` label
22
+ 3. Submit a pull request with your improvements
23
+
24
+ ## Support
25
+
26
+ For questions and support:
27
+
28
+ - 📚 Read the documentation in this folder
29
+ - 🐛 Report bugs via [GitHub Issues](https://github.com/hyochan/expo-iap/issues)
30
+ - 💬 Join discussions in [GitHub Discussions](https://github.com/hyochan/expo-iap/discussions)