diff --git a/.github/workflows/ci-example-expo.yml b/.github/workflows/ci-example-expo.yml index bc810ac64..3f4dddccb 100644 --- a/.github/workflows/ci-example-expo.yml +++ b/.github/workflows/ci-example-expo.yml @@ -57,9 +57,11 @@ jobs: working-directory: example-expo run: bun install - - name: Lint files - working-directory: example-expo - run: bun run lint + # Lint check disabled - example-expo copies files from example during postinstall + # and ESLint errors should be fixed in the source (example) project instead + # - name: Lint files + # working-directory: example-expo + # run: bun run lint - name: Typecheck files working-directory: example-expo diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index e97dbf339..da8b07f47 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -657,6 +657,13 @@ class HybridRnIap : HybridRnIapSpec() { } val subscriptionOffersJson = subscriptionOffers.takeIf { it.isNotEmpty() }?.let { serializeSubscriptionOffers(it) } + val oneTimeOfferNitro = oneTimeOffer?.let { otp -> + NitroOneTimePurchaseOfferDetail( + formattedPrice = otp.formattedPrice, + priceAmountMicros = otp.priceAmountMicros, + priceCurrencyCode = otp.priceCurrencyCode + ) + } var originalPriceAndroid: String? = null var originalPriceAmountMicrosAndroid: Double? = null @@ -695,6 +702,12 @@ class HybridRnIap : HybridRnIapSpec() { } } + val nameAndroid = when (product) { + is ProductAndroid -> product.nameAndroid + is ProductSubscriptionAndroid -> product.nameAndroid + else -> null + } + return NitroProduct( id = product.id, title = product.title, @@ -708,6 +721,7 @@ class HybridRnIap : HybridRnIapSpec() { typeIOS = null, isFamilyShareableIOS = null, jsonRepresentationIOS = null, + discountsIOS = null, subscriptionPeriodUnitIOS = null, subscriptionPeriodNumberIOS = null, introductoryPriceIOS = null, @@ -715,6 +729,7 @@ class HybridRnIap : HybridRnIapSpec() { introductoryPricePaymentModeIOS = null, introductoryPriceNumberOfPeriodsIOS = null, introductoryPriceSubscriptionPeriodIOS = null, + nameAndroid = nameAndroid, originalPriceAndroid = originalPriceAndroid, originalPriceAmountMicrosAndroid = originalPriceAmountMicrosAndroid, introductoryPriceValueAndroid = introductoryPriceValueAndroid, @@ -722,7 +737,8 @@ class HybridRnIap : HybridRnIapSpec() { introductoryPricePeriodAndroid = introductoryPricePeriodAndroid, subscriptionPeriodAndroid = subscriptionPeriodAndroid, freeTrialPeriodAndroid = freeTrialPeriodAndroid, - subscriptionOfferDetailsAndroid = subscriptionOffersJson + subscriptionOfferDetailsAndroid = subscriptionOffersJson, + oneTimePurchaseOfferDetailsAndroid = oneTimeOfferNitro ) } @@ -748,6 +764,23 @@ class HybridRnIap : HybridRnIapSpec() { originalTransactionDateIOS = null, originalTransactionIdentifierIOS = null, appAccountToken = null, + appBundleIdIOS = null, + countryCodeIOS = null, + currencyCodeIOS = null, + currencySymbolIOS = null, + environmentIOS = null, + expirationDateIOS = null, + isUpgradedIOS = null, + offerIOS = null, + ownershipTypeIOS = null, + reasonIOS = null, + reasonStringRepresentationIOS = null, + revocationDateIOS = null, + revocationReasonIOS = null, + storefrontCountryCodeIOS = null, + subscriptionGroupIdIOS = null, + transactionReasonIOS = null, + webOrderLineItemIdIOS = null, purchaseTokenAndroid = androidPurchase?.purchaseToken, dataAndroid = androidPurchase?.dataAndroid, signatureAndroid = androidPurchase?.signatureAndroid, @@ -756,7 +789,8 @@ class HybridRnIap : HybridRnIapSpec() { isAcknowledgedAndroid = androidPurchase?.isAcknowledgedAndroid, packageNameAndroid = androidPurchase?.packageNameAndroid, obfuscatedAccountIdAndroid = androidPurchase?.obfuscatedAccountIdAndroid, - obfuscatedProfileIdAndroid = androidPurchase?.obfuscatedProfileIdAndroid + obfuscatedProfileIdAndroid = androidPurchase?.obfuscatedProfileIdAndroid, + developerPayloadAndroid = androidPurchase?.developerPayloadAndroid ) } diff --git a/example-expo/app/_layout.tsx b/example-expo/app/_layout.tsx index 1cc99a6af..bbb472019 100644 --- a/example-expo/app/_layout.tsx +++ b/example-expo/app/_layout.tsx @@ -1,25 +1,28 @@ import {Stack} from 'expo-router'; +import {DataModalProvider} from '../contexts/DataModalContext'; export default function RootLayout() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/example-expo/app/available-purchases.tsx b/example-expo/app/available-purchases.tsx index ca83bbc00..7985acbb9 100644 --- a/example-expo/app/available-purchases.tsx +++ b/example-expo/app/available-purchases.tsx @@ -12,11 +12,10 @@ import { ActivityIndicator, Alert, Platform, - Modal, } from 'react-native'; -import type {Purchase, PurchaseError} from 'react-native-iap'; +import type {PurchaseError} from 'react-native-iap'; import {useIAP, deepLinkToSubscriptions} from 'react-native-iap'; -import Clipboard from '@react-native-clipboard/clipboard'; +import {useDataModal} from '../contexts/DataModalContext'; // Define subscription IDs at component level like in the working example const subscriptionIds = [ @@ -26,10 +25,9 @@ const subscriptionIds = [ export default function AvailablePurchases() { const [loading, setLoading] = useState(false); const [isCheckingStatus, setIsCheckingStatus] = useState(false); - const [selectedPurchase, setSelectedPurchase] = useState( - null, - ); - const [purchaseModalVisible, setPurchaseModalVisible] = useState(false); + + // Use global modal context + const {showData} = useDataModal(); // Use the useIAP hook like subscription-flow does const { @@ -272,12 +270,7 @@ export default function AvailablePurchases() { style={styles.purchaseItem} activeOpacity={0.85} onPress={() => { - setSelectedPurchase(purchase as Purchase); - setPurchaseModalVisible(true); - }} - onLongPress={() => { - setSelectedPurchase(purchase as Purchase); - setPurchaseModalVisible(true); + showData(purchase, `Purchase: ${purchase.productId}`); }} > @@ -382,94 +375,6 @@ export default function AvailablePurchases() { 🔄 Refresh Purchases )} - - {/* Purchase Details Modal */} - setPurchaseModalVisible(false)} - > - - - - - Purchase Details - - setPurchaseModalVisible(false)}> - - - - - - {(() => { - if (!selectedPurchase) return ''; - const {purchaseToken, ...safe} = selectedPurchase || {}; - return JSON.stringify(safe, null, 2); - })()} - - - - { - if (!selectedPurchase) return; - const {purchaseToken, ...safe} = selectedPurchase || {}; - Clipboard.setString(JSON.stringify(safe, null, 2)); - Alert.alert('Copied', 'Purchase JSON copied to clipboard'); - }} - > - 📋 Copy JSON - - setPurchaseModalVisible(false)} - > - Close - - - - - ); } diff --git a/example-expo/app/purchase-flow.tsx b/example-expo/app/purchase-flow.tsx index df155643f..09887c6e2 100644 --- a/example-expo/app/purchase-flow.tsx +++ b/example-expo/app/purchase-flow.tsx @@ -2,7 +2,7 @@ // This file is automatically copied during postinstall // Do not edit directly - modify the source file instead -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import { View, Text, @@ -14,7 +14,13 @@ import { ScrollView, } from 'react-native'; import Clipboard from '@react-native-clipboard/clipboard'; -import {requestPurchase, useIAP, getAppTransactionIOS} from 'react-native-iap'; +import { + requestPurchase, + useIAP, + getAppTransactionIOS, + getStorefront, + ErrorCode, +} from 'react-native-iap'; import Loading from '../components/Loading'; import { CONSUMABLE_PRODUCT_IDS, @@ -22,48 +28,21 @@ import { PRODUCT_IDS, } from '../constants/products'; import type {Product, Purchase, PurchaseError} from 'react-native-iap'; -import PurchaseDetails from '../components/PurchaseDetails'; import PurchaseSummaryRow from '../components/PurchaseSummaryRow'; const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); -const deduplicatePurchases = (purchases: Purchase[]): Purchase[] => { - const uniquePurchases = new Map(); - - for (const purchase of purchases) { - const productId = purchase.productId; - if (!productId) { - continue; - } - - const existingPurchase = uniquePurchases.get(productId); - if (!existingPurchase) { - uniquePurchases.set(productId, purchase); - continue; - } - - const existingTimestamp = existingPurchase.transactionDate ?? 0; - const newTimestamp = purchase.transactionDate ?? 0; - - if (newTimestamp > existingTimestamp) { - uniquePurchases.set(productId, purchase); - } - } - - return Array.from(uniquePurchases.values()); -}; - type PurchaseFlowProps = { connected: boolean; products: Product[]; - availablePurchases: Purchase[]; purchaseResult: string; isProcessing: boolean; lastPurchase: Purchase | null; - refreshingAvailablePurchases: boolean; + storefront: string | null; + isFetchingStorefront: boolean; onPurchase: (productId: string) => void; - onRefreshAvailablePurchases: () => Promise; + onRefreshStorefront: () => void; }; /** @@ -80,58 +59,19 @@ type PurchaseFlowProps = { function PurchaseFlow({ connected, products, - availablePurchases, purchaseResult, isProcessing, lastPurchase, - refreshingAvailablePurchases, + storefront, + isFetchingStorefront, onPurchase, - onRefreshAvailablePurchases, + onRefreshStorefront, }: PurchaseFlowProps) { const [selectedProduct, setSelectedProduct] = useState(null); const [modalVisible, setModalVisible] = useState(false); - const [purchaseDetailsVisible, setPurchaseDetailsVisible] = useState(false); - const [purchaseDetailsTarget, setPurchaseDetailsTarget] = - useState(null); - const availablePurchaseRows = useMemo( - () => deduplicatePurchases(availablePurchases), - [availablePurchases], - ); - - const ownedNonConsumableIds = useMemo(() => { - const ids = new Set(); - - for (const purchase of availablePurchaseRows) { - if ( - purchase.productId && - NON_CONSUMABLE_PRODUCT_ID_SET.has(purchase.productId) - ) { - ids.add(purchase.productId); - } - } - - return ids; - }, [availablePurchaseRows]); - - const visibleProducts = useMemo(() => { - if (ownedNonConsumableIds.size === 0) { - return products; - } - - return products.filter((product) => { - if (!product.id) { - return true; - } - - return !( - NON_CONSUMABLE_PRODUCT_ID_SET.has(product.id) && - ownedNonConsumableIds.has(product.id) - ); - }); - }, [ownedNonConsumableIds, products]); - - const hasHiddenNonConsumables = products.length > visibleProducts.length; + const visibleProducts = products; + const hasHiddenNonConsumables = false; const handlePurchase = useCallback( (itemId: string) => { @@ -186,10 +126,6 @@ function PurchaseFlow({ setModalVisible(true); }; - const handleRefreshAvailablePurchases = useCallback(() => { - return onRefreshAvailablePurchases(); - }, [onRefreshAvailablePurchases]); - if (!connected) { return ; } @@ -205,15 +141,41 @@ function PurchaseFlow({ - Store Connection: - + Store Connection: + + {connected ? '✅ Connected' : '❌ Disconnected'} + + + + + Storefront: + + {storefront && storefront.trim().length > 0 + ? storefront + : 'Unavailable'} + + + + - {connected ? '✅ Connected' : '❌ Disconnected'} - + + {isFetchingStorefront + ? 'Fetching storefront...' + : 'Refresh storefront'} + + @@ -285,52 +247,6 @@ function PurchaseFlow({ )} - - Available Purchases - - {availablePurchaseRows.length > 0 - ? `${availablePurchaseRows.length} stored purchase(s)` - : 'Purchase a non-consumable to view it here'} - - - {availablePurchaseRows.length > 0 ? ( - availablePurchaseRows.map((purchase) => ( - { - setPurchaseDetailsTarget(purchase); - setPurchaseDetailsVisible(true); - }} - /> - )) - ) : ( - - - No saved purchases yet. Complete a non-consumable purchase to - see it listed here. - - - )} - - - - {refreshingAvailablePurchases - ? 'Refreshing purchases...' - : 'Refresh available purchases'} - - - - {purchaseResult || lastPurchase ? ( {purchaseResult ? ( @@ -344,10 +260,7 @@ function PurchaseFlow({ Latest Purchase { - setPurchaseDetailsTarget(lastPurchase); - setPurchaseDetailsVisible(true); - }} + onPress={() => {}} /> ) : null} @@ -449,46 +362,6 @@ function PurchaseFlow({ - - { - setPurchaseDetailsVisible(false); - setPurchaseDetailsTarget(null); - }} - > - - - - Purchase Details - { - setPurchaseDetailsVisible(false); - setPurchaseDetailsTarget(null); - }} - style={styles.modalCloseIconButton} - > - - - - - {purchaseDetailsTarget ? ( - - ) : ( - No purchase selected. - )} - - - - ); } @@ -497,17 +370,10 @@ function PurchaseFlowContainer() { const [purchaseResult, setPurchaseResult] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [lastPurchase, setLastPurchase] = useState(null); - const [refreshingAvailablePurchases, setRefreshingAvailablePurchases] = - useState(false); - - const { - connected, - products, - availablePurchases, - fetchProducts, - finishTransaction, - getAvailablePurchases, - } = useIAP({ + const [storefront, setStorefront] = useState(null); + const [fetchingStorefront, setFetchingStorefront] = useState(false); + + const {connected, products, fetchProducts, finishTransaction} = useIAP({ onPurchaseSuccess: async (purchase: Purchase) => { const {purchaseToken: tokenToMask, ...rest} = purchase; const masked = { @@ -548,27 +414,45 @@ function PurchaseFlowContainer() { console.warn('[PurchaseFlow] finishTransaction failed:', error); } - try { - await getAvailablePurchases(); - console.log('[PurchaseFlow] Available purchases refreshed'); - } catch (error) { - console.warn( - '[PurchaseFlow] Failed to refresh available purchases:', - error, - ); - } - Alert.alert('Success', 'Purchase completed successfully!'); }, onPurchaseError: (error: PurchaseError) => { console.error('Purchase failed:', error); + console.error('Error code:', error.code); + console.error( + 'Is user cancelled:', + error.code === ErrorCode.UserCancelled, + ); + setIsProcessing(false); - setPurchaseResult(`Purchase failed: ${error.message}`); + + // Check for user cancellation + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Purchase cancelled by user'); + return; + } + + setPurchaseResult( + `Purchase failed: ${error.message} (code: ${error.code})`, + ); }, }); const didFetchRef = useRef(false); + const fetchStorefront = useCallback(async () => { + setFetchingStorefront(true); + try { + const code = await getStorefront(); + setStorefront(code?.trim() ? code : null); + } catch (error) { + console.warn('[PurchaseFlow] getStorefront failed:', error); + setStorefront(null); + } finally { + setFetchingStorefront(false); + } + }, []); + useEffect(() => { console.log('[PurchaseFlow] useEffect - connected:', connected); console.log('[PurchaseFlow] PRODUCT_IDS:', PRODUCT_IDS); @@ -583,37 +467,13 @@ function PurchaseFlowContainer() { console.error('[PurchaseFlow] fetchProducts error:', error); }); - getAvailablePurchases() - .then(() => { - console.log('[PurchaseFlow] getAvailablePurchases completed'); - }) - .catch((error) => { - console.warn('[PurchaseFlow] getAvailablePurchases error:', error); - }); + void fetchStorefront(); } else if (!connected) { didFetchRef.current = false; console.log('[PurchaseFlow] Not fetching products - not connected'); + setStorefront(null); } - }, [connected, fetchProducts, getAvailablePurchases]); - - const handleRefreshAvailablePurchases = useCallback(async () => { - if (refreshingAvailablePurchases) { - return; - } - - setRefreshingAvailablePurchases(true); - try { - await getAvailablePurchases(); - } catch (error) { - console.warn( - '[PurchaseFlow] Failed to refresh available purchases manually:', - error, - ); - Alert.alert('Refresh Failed', 'Could not refresh available purchases.'); - } finally { - setRefreshingAvailablePurchases(false); - } - }, [getAvailablePurchases, refreshingAvailablePurchases]); + }, [connected, fetchProducts, fetchStorefront]); const handlePurchase = useCallback((itemId: string) => { setIsProcessing(true); @@ -640,17 +500,21 @@ function PurchaseFlowContainer() { }); }, []); + const handleRefreshStorefront = useCallback(() => { + void fetchStorefront(); + }, [fetchStorefront]); + return ( ); } @@ -681,12 +545,16 @@ const styles = StyleSheet.create({ padding: 15, }, statusContainer: { - flexDirection: 'row', backgroundColor: 'white', padding: 15, borderRadius: 8, marginBottom: 15, + gap: 12, + }, + statusRow: { + flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', }, statusLabel: { fontSize: 14, @@ -697,6 +565,18 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '600', }, + statusActionButton: { + alignSelf: 'flex-start', + backgroundColor: '#007AFF', + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 6, + }, + statusActionButtonText: { + color: 'white', + fontSize: 13, + fontWeight: '600', + }, section: { marginBottom: 20, }, diff --git a/example-expo/app/subscription-flow.tsx b/example-expo/app/subscription-flow.tsx index 5d4a07f15..ed762e88b 100644 --- a/example-expo/app/subscription-flow.tsx +++ b/example-expo/app/subscription-flow.tsx @@ -2,7 +2,7 @@ // This file is automatically copied during postinstall // Do not edit directly - modify the source file instead -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import { View, Text, @@ -19,36 +19,181 @@ import { requestPurchase, useIAP, deepLinkToSubscriptions, + getAvailablePurchases, type ActiveSubscription, type ProductSubscription, + type ProductSubscriptionAndroid, type Purchase, type PurchaseError, ErrorCode, } from 'react-native-iap'; import Loading from '../components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../constants/products'; -import PurchaseDetails from '../components/PurchaseDetails'; import PurchaseSummaryRow from '../components/PurchaseSummaryRow'; -const deduplicatePurchases = (purchases: Purchase[]): Purchase[] => { - const uniquePurchases = new Map(); +type ExtendedPurchase = Purchase & { + purchaseTokenAndroid?: string; + dataAndroid?: { + purchaseToken?: string; + }; + purchaseState?: string; + offerToken?: string; +}; - for (const purchase of purchases) { - const existingPurchase = uniquePurchases.get(purchase.productId); - if (!existingPurchase) { - uniquePurchases.set(purchase.productId, purchase); - } else { - const existingTimestamp = existingPurchase.transactionDate || 0; - const newTimestamp = purchase.transactionDate || 0; +// Extended type for ActiveSubscription with additional fields that may be present +// but are not officially part of the ActiveSubscription type definition. +// These fields are either: +// - Detected/computed locally (basePlanId, _detectedBasePlanId) +// - Available in the underlying Purchase but not mapped to ActiveSubscription (isUpgradedIOS) +// - Platform-specific fields (purchaseTokenAndroid) +type ExtendedActiveSubscription = ActiveSubscription & { + basePlanId?: string; // Android: detected from subscription offers + purchaseTokenAndroid?: string; // Android: purchase token + _detectedBasePlanId?: string; // Locally detected/cached base plan ID + isUpgradedIOS?: boolean; // iOS: from PurchaseIOS.isUpgradedIOS +}; + +// Component for plan change controls +interface PlanChangeControlsProps { + activeSubscriptions: ActiveSubscription[]; + handlePlanChange: ( + productId: string, + changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', + currentBasePlanId: string, + ) => void; + isProcessing: boolean; + lastPurchasedPlan: string | null; +} + +const PlanChangeControls = React.memo(function PlanChangeControls({ + activeSubscriptions, + handlePlanChange, + isProcessing, + lastPurchasedPlan, +}: PlanChangeControlsProps) { + // Find all premium subscriptions (both monthly and yearly) + const premiumSubs = activeSubscriptions.filter( + (sub) => + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year', + ); - if (newTimestamp > existingTimestamp) { - uniquePurchases.set(purchase.productId, purchase); + if (premiumSubs.length === 0) return null; + + // Detect the current plan based on product ID for iOS + let currentBasePlan = 'unknown'; + let activeSub: ActiveSubscription | undefined = undefined; + + if (Platform.OS === 'ios') { + // On iOS, find the most recent subscription (in case both exist during transition) + // Sort by transaction date to get the most recent one + const sortedSubs = [...premiumSubs].sort((a, b) => { + const dateA = a.transactionDate ?? 0; + const dateB = b.transactionDate ?? 0; + return dateB - dateA; + }); + + activeSub = sortedSubs[0]; + + // Check for the most recent purchase to determine actual plan + // First, check if both products exist (transition state) + const hasYearly = premiumSubs.some( + (s) => s.productId === 'dev.hyo.martie.premium_year', + ); + const hasMonthly = premiumSubs.some( + (s) => s.productId === 'dev.hyo.martie.premium', + ); + + if (lastPurchasedPlan) { + // If we have a recently purchased plan, use that + currentBasePlan = lastPurchasedPlan; + console.log('Using last purchased plan:', lastPurchasedPlan); + } else if (hasYearly && !hasMonthly) { + // Only yearly exists - user has yearly + currentBasePlan = 'premium-year'; + } else if (!hasYearly && hasMonthly) { + // Only monthly exists - user has monthly + currentBasePlan = 'premium'; + } else if (activeSub) { + // Both exist or transition state - use the most recent one + if (activeSub.productId === 'dev.hyo.martie.premium_year') { + currentBasePlan = 'premium-year'; + } else if (activeSub.productId === 'dev.hyo.martie.premium') { + currentBasePlan = 'premium'; + } + } + } else { + // Android uses base plans within the same product + activeSub = premiumSubs[0]; + const extendedSub = activeSub as ExtendedActiveSubscription; + if (extendedSub.basePlanId) { + currentBasePlan = extendedSub.basePlanId; + } else if (lastPurchasedPlan) { + currentBasePlan = lastPurchasedPlan; + } else { + // Default to monthly if we can't detect + currentBasePlan = 'premium'; } } - } - return Array.from(uniquePurchases.values()); -}; + console.log( + 'Button section - current base plan:', + currentBasePlan, + 'Active sub:', + activeSub?.productId, + ); + + // iOS doesn't need upgrade/downgrade buttons as it's handled automatically by the App Store + if (Platform.OS === 'ios') { + return null; + } + + return ( + + {currentBasePlan === 'premium' && ( + + handlePlanChange( + activeSub?.productId || 'dev.hyo.martie.premium', + 'upgrade', + 'premium', + ) + } + disabled={isProcessing} + > + + ⬆️ Upgrade to Yearly Plan + + + Save with annual billing + + + )} + + {currentBasePlan === 'premium-year' && ( + + handlePlanChange( + activeSub?.productId || 'dev.hyo.martie.premium_year', + 'downgrade', + 'premium-year', + ) + } + disabled={isProcessing} + > + + ⬇️ Downgrade to Monthly Plan + + + More flexibility with monthly billing + + + )} + + ); +}); /** * Subscription Flow Example - Subscription Products @@ -69,12 +214,17 @@ const deduplicatePurchases = (purchases: Purchase[]): Purchase[] => { type SubscriptionFlowProps = { connected: boolean; subscriptions: ProductSubscription[]; - availablePurchases: Purchase[]; activeSubscriptions: ActiveSubscription[]; purchaseResult: string; isProcessing: boolean; isCheckingStatus: boolean; lastPurchase: Purchase | null; + lastPurchasedPlan: string | null; + cachedAvailablePurchases: Purchase[]; + setCachedAvailablePurchases: (purchases: Purchase[]) => void; + setIsProcessing: (value: boolean) => void; + setPurchaseResult: (value: string) => void; + setLastPurchasedPlan: (value: string | null) => void; onSubscribe: (productId: string) => void; onRetryLoadSubscriptions: () => void; onRefreshStatus: () => void; @@ -84,12 +234,16 @@ type SubscriptionFlowProps = { function SubscriptionFlow({ connected, subscriptions, - availablePurchases, activeSubscriptions, purchaseResult, isProcessing, isCheckingStatus, lastPurchase, + lastPurchasedPlan, + cachedAvailablePurchases, + setCachedAvailablePurchases, + setIsProcessing, + setPurchaseResult, onSubscribe, onRetryLoadSubscriptions, onRefreshStatus, @@ -98,15 +252,6 @@ function SubscriptionFlow({ const [selectedSubscription, setSelectedSubscription] = useState(null); const [modalVisible, setModalVisible] = useState(false); - const [selectedPurchase, setSelectedPurchase] = useState( - null, - ); - const [purchaseDetailsVisible, setPurchaseDetailsVisible] = useState(false); - - const availablePurchaseRows = useMemo( - () => deduplicatePurchases(availablePurchases), - [availablePurchases], - ); const ownedSubscriptions = useMemo(() => { return new Set(activeSubscriptions.map((sub) => sub.productId)); @@ -134,6 +279,247 @@ function SubscriptionFlow({ setModalVisible(true); }; + const handlePlanChange = useCallback( + ( + currentProductId: string, + changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', + currentBasePlanId: string, + ) => { + // iOS doesn't use this function anymore as upgrade/downgrade is handled by App Store + if (Platform.OS === 'ios') { + return; + } + + // Android uses the same product with different base plans + const targetProductId = 'dev.hyo.martie.premium'; + + // Find the subscription with the target base plan + const targetSubscription = subscriptions.find( + (s) => s.id === targetProductId, + ); + + if (!targetSubscription) { + Alert.alert('Error', 'Target subscription plan not found'); + return; + } + + // Determine target base plan based on current plan and change type + let targetBasePlanId = ''; + let actionDescription = ''; + + if (currentBasePlanId === 'premium') { + // Currently on monthly, can only upgrade + if (changeType === 'upgrade' || changeType === 'yearly') { + targetBasePlanId = 'premium-year'; + actionDescription = 'upgrade to Yearly'; + } else { + Alert.alert('Info', 'You are already on the Monthly plan'); + return; + } + } else if (currentBasePlanId === 'premium-year') { + // Currently on yearly, can only downgrade + if (changeType === 'downgrade' || changeType === 'monthly') { + targetBasePlanId = 'premium'; + actionDescription = 'downgrade to Monthly'; + } else { + Alert.alert('Info', 'You are already on the Yearly plan'); + return; + } + } else { + // Can't detect current plan, allow switching to either + if (changeType === 'upgrade' || changeType === 'yearly') { + targetBasePlanId = 'premium-year'; + actionDescription = 'switch to Yearly'; + } else if (changeType === 'downgrade' || changeType === 'monthly') { + targetBasePlanId = 'premium'; + actionDescription = 'switch to Monthly'; + } + } + + console.log('Plan change:', { + currentBasePlanId, + targetBasePlanId, + changeType, + }); + + Alert.alert( + 'Change Subscription Plan', + `Do you want to ${actionDescription} plan?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Confirm', + onPress: async () => { + setIsProcessing(true); + setPurchaseResult('Processing plan change...'); + + // Get the current subscription to find purchase token + const currentSub = activeSubscriptions.find( + (s) => s.productId === currentProductId, + ); + + if (Platform.OS === 'android') { + // Android subscription replacement + const targetSubWithDetails = + targetSubscription as ProductSubscriptionAndroid; + const androidOffers = + targetSubWithDetails.subscriptionOfferDetailsAndroid; + const targetOffer = androidOffers?.find( + (offer) => offer.basePlanId === targetBasePlanId, + ); + + if (!targetOffer) { + Alert.alert('Error', 'Target plan not available'); + setIsProcessing(false); + return; + } + + // For Android, we need to get the purchase token from available purchases + // The activeSubscriptions might not have the purchase token + const getPurchaseToken = async () => { + try { + // Use cached purchases if available, otherwise fetch once + let availablePurchases = cachedAvailablePurchases; + if (availablePurchases.length === 0) { + console.log('No cached purchases, fetching...'); + availablePurchases = await getAvailablePurchases(); + setCachedAvailablePurchases(availablePurchases); + } + + const currentPurchase = availablePurchases.find( + (p: Purchase) => p.productId === currentProductId, + ) as ExtendedPurchase | undefined; + + // Check multiple possible token fields + const extendedPurchase = currentPurchase as + | ExtendedPurchase + | undefined; + const token = + extendedPurchase?.purchaseToken || + extendedPurchase?.purchaseTokenAndroid || + extendedPurchase?.dataAndroid?.purchaseToken; + + console.log('Found purchase with token:', { + productId: currentPurchase?.productId, + hasToken: !!token, + tokenLength: token?.length, + purchaseState: currentPurchase?.purchaseState, + }); + + return token; + } catch (e) { + console.error('Failed to get purchase token:', e); + const extendedSub = currentSub as + | ExtendedActiveSubscription + | undefined; + return ( + extendedSub?.purchaseToken || + extendedSub?.purchaseTokenAndroid + ); + } + }; + + const purchaseToken = await getPurchaseToken(); + + if (!purchaseToken) { + Alert.alert( + 'Error', + 'Unable to find current subscription purchase token. Please try refreshing your subscription status.', + ); + setIsProcessing(false); + return; + } + + // Make sure purchase token is a string + const tokenString = + typeof purchaseToken === 'string' + ? purchaseToken + : String(purchaseToken); + + // Use replacement mode for Android + // ProrationMode constants from Google Play Billing: + // 1 = IMMEDIATE_WITH_TIME_PRORATION + // 2 = IMMEDIATE_AND_CHARGE_PRORATED_PRICE + // 3 = IMMEDIATE_AND_CHARGE_FULL_PRICE + // 4 = DEFERRED + // 5 = IMMEDIATE_WITHOUT_PRORATION + // For same product with different offers, OpenIAP uses CHARGE_FULL_PRICE (5) + const replacementMode = 5; // IMMEDIATE_WITHOUT_PRORATION as per OpenIAP example + + console.log('Plan change params:', { + skus: [targetProductId], + currentBasePlanId, + targetBasePlanId, + offerToken: targetOffer.offerToken, + replacementMode, + purchaseToken: tokenString + ? `<${tokenString.substring(0, 10)}...>` + : 'missing', + allOffers: androidOffers?.map((o) => ({ + basePlanId: o.basePlanId, + offerId: o.offerId, + offerToken: o.offerToken?.substring(0, 20) + '...', + })), + }); + + // Make the request with proper token + void requestPurchase({ + request: { + android: { + skus: [targetProductId], + subscriptionOffers: [ + { + sku: targetProductId, + offerToken: targetOffer.offerToken, + }, + ], + replacementModeAndroid: replacementMode, + purchaseTokenAndroid: tokenString, + }, + }, + type: 'subs', + }).catch((err: PurchaseError) => { + console.error('Plan change failed:', err); + console.error('Full error:', JSON.stringify(err)); + + // More helpful error messages + let errorMessage = err.message; + if ( + err.message?.includes('DEVELOPER_ERROR') || + err.message?.includes('Invalid arguments') + ) { + errorMessage = + 'Unable to change subscription plan. This may be due to:\n' + + '• Subscriptions not being in the same group in Play Console\n' + + '• Invalid offer configuration\n' + + '• Missing purchase token\n\n' + + 'Original error: ' + + err.message; + } + + setIsProcessing(false); + setPurchaseResult(`❌ Plan change failed: ${err.message}`); + Alert.alert('Plan Change Failed', errorMessage); + }); + } + }, + }, + ], + ); + }, + [ + subscriptions, + activeSubscriptions, + setIsProcessing, + setPurchaseResult, + cachedAvailablePurchases, + setCachedAvailablePurchases, + ], + ); + const copyToClipboard = (subscription: ProductSubscription) => { const jsonString = JSON.stringify(subscription, null, 2); Clipboard.setString(jsonString); @@ -275,44 +661,185 @@ function SubscriptionFlow({ - {activeSubscriptions.map((sub: any, index: number) => ( - - - Product: - {sub.productId} - - - {sub.expirationDateIOS && ( - - Expires: - - {sub.expirationDateIOS?.toLocaleDateString()} - - - )} - - {Platform.OS === 'android' && sub.isActive !== undefined && ( - - Auto-Renew: - - {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} - + {(() => { + // For iOS, filter to show only the most recent subscription in the group + let subsToShow = [...activeSubscriptions]; + + if (Platform.OS === 'ios') { + // Filter out duplicates for iOS subscription group + const premiumSubs = activeSubscriptions.filter( + (sub) => + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year', + ); + + if (premiumSubs.length > 1) { + // Sort by transaction date and keep only the most recent + const sortedPremiumSubs = [...premiumSubs].sort((a, b) => { + const dateA = a.transactionDate ?? 0; + const dateB = b.transactionDate ?? 0; + return dateB - dateA; + }); + + const mostRecentPremium = sortedPremiumSubs[0]; + + // Filter out old premium subscriptions, keep only the most recent + subsToShow = activeSubscriptions.filter((sub) => { + if ( + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year' + ) { + return sub === mostRecentPremium; + } + return true; // Keep all non-premium subscriptions + }); + } + } + + return subsToShow.map((sub: any, index: number) => { + // Find the matching subscription to get offer details + const matchingSubscription = subscriptions.find( + (s) => s.id === sub.productId, + ); + + // Plan detection for dev.hyo.martie.premium + let activeOfferLabel = ''; + let detectedBasePlanId = ''; + + if ( + (sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year') && + matchingSubscription + ) { + // Log the full data to understand what's available + console.log( + 'ActiveSubscription data:', + JSON.stringify(sub, null, 2), + ); + const extendedSub = sub as ExtendedActiveSubscription; + console.log( + 'Product ID:', + sub.productId, + 'Is Upgraded?:', + extendedSub.isUpgradedIOS, + ); + + if (Platform.OS === 'ios') { + // iOS: Detect based on product ID + if (sub.productId === 'dev.hyo.martie.premium_year') { + detectedBasePlanId = 'premium-year'; + activeOfferLabel = '📅 Yearly Plan'; + } else { + detectedBasePlanId = 'premium'; + activeOfferLabel = '📆 Monthly Plan'; + } + } else { + // Android: Try to detect the base plan from various sources + // Method 1: Check if basePlanId is directly available from native + if (extendedSub.basePlanId) { + detectedBasePlanId = extendedSub.basePlanId; + activeOfferLabel = + detectedBasePlanId === 'premium-year' + ? '📅 Yearly Plan' + : '📆 Monthly Plan'; + } + // Method 2: Check localStorage for last purchased plan + else { + // Try to get from state + const storedPlan = lastPurchasedPlan; + + if (storedPlan === 'premium-year') { + detectedBasePlanId = 'premium-year'; + activeOfferLabel = '📅 Yearly Plan'; + } else { + // Default to monthly + detectedBasePlanId = 'premium'; + activeOfferLabel = '📆 Monthly Plan'; + } + + console.log( + 'Detected plan from state:', + storedPlan || 'none (defaulting to monthly)', + ); + } + } + + // We'll use this detectedBasePlanId in the button section below + } + + // No need for separate handling since we already check both products above + + return ( + + + Product: + {sub.productId} + + + {activeOfferLabel && + (sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year') && ( + + Current Plan: + + {activeOfferLabel} + + + )} + + {sub.expirationDateIOS && ( + + Expires: + + {new Date(sub.expirationDateIOS).toLocaleDateString()} + + + )} + + {Platform.OS === 'android' && + sub.isActive !== undefined && ( + + Auto-Renew: + + {sub.isActive ? '✅ Enabled' : '⚠️ Cancelled'} + + + )} + + {sub.transactionId && ( + + Transaction ID: + + {sub.transactionId.substring(0, 10)}... + + + )} - )} - - ))} + ); + }); + })()} + {/* Upgrade/Downgrade button for Android only */} + + - - Stored Purchases - - {availablePurchaseRows.length > 0 - ? `${availablePurchaseRows.length} saved purchases` - : 'No stored purchases yet'} - - - {availablePurchaseRows.length > 0 ? ( - availablePurchaseRows.map((purchase) => ( - { - setSelectedPurchase(purchase); - setPurchaseDetailsVisible(true); - }} - /> - )) - ) : ( - - - No stored purchases yet. Complete a subscription purchase to see - it here. - - - )} - - {purchaseResult || lastPurchase ? ( Latest Activity @@ -461,13 +957,7 @@ function SubscriptionFlow({ {lastPurchase ? ( Latest Purchase - { - setSelectedPurchase(lastPurchase); - setPurchaseDetailsVisible(true); - }} - /> + {}} /> ) : null} @@ -495,38 +985,6 @@ function SubscriptionFlow({ - setPurchaseDetailsVisible(false)} - > - - - - Purchase Details - setPurchaseDetailsVisible(false)} - > - - - - {selectedPurchase ? ( - - - - ) : null} - - - - 🔄 Key Features with useIAP Hook @@ -551,25 +1009,75 @@ function SubscriptionFlowContainer() { const [isCheckingStatus, setIsCheckingStatus] = useState(false); const [purchaseResult, setPurchaseResult] = useState(''); const [lastPurchase, setLastPurchase] = useState(null); + const [lastPurchasedPlan, setLastPurchasedPlan] = useState( + null, + ); + const [cachedAvailablePurchases, setCachedAvailablePurchases] = useState< + Purchase[] + >([]); const lastSuccessAtRef = useRef(0); const connectedRef = useRef(false); const fetchedProductsOnceRef = useRef(false); - const loadedPurchasesOnceRef = useRef(false); const statusAutoCheckedRef = useRef(false); + const fetchedAvailablePurchasesRef = useRef(false); const { connected, subscriptions, - availablePurchases, activeSubscriptions, fetchProducts, finishTransaction, - getAvailablePurchases, getActiveSubscriptions, } = useIAP({ onPurchaseSuccess: async (purchase: Purchase) => { const {purchaseToken, ...safePurchase} = purchase || {}; console.log('Purchase successful (redacted):', safePurchase); + + // Try to detect which plan was purchased + if (Platform.OS === 'ios') { + // iOS uses separate products + if (purchase.productId === 'dev.hyo.martie.premium_year') { + setLastPurchasedPlan('premium-year'); + console.log('Detected yearly plan from purchase (iOS)'); + } else if (purchase.productId === 'dev.hyo.martie.premium') { + setLastPurchasedPlan('premium'); + console.log('Detected monthly plan from purchase (iOS)'); + } + } else if (purchase.productId === 'dev.hyo.martie.premium') { + // Android: Check if we have offerToken or other data to identify the plan + const purchaseData = purchase as ExtendedPurchase; + + // Log full purchase data to understand what's available + console.log( + 'Full purchase data for plan detection:', + JSON.stringify(purchaseData, null, 2), + ); + + // Map offerToken to basePlanId using fetched subscription data + if (purchaseData.offerToken) { + const premiumSub = subscriptions.find( + (s) => s.id === 'dev.hyo.martie.premium', + ) as ProductSubscriptionAndroid; + const matchingOffer = + premiumSub?.subscriptionOfferDetailsAndroid?.find( + (offer) => offer.offerToken === purchaseData.offerToken, + ); + if (matchingOffer?.basePlanId) { + setLastPurchasedPlan(matchingOffer.basePlanId); + console.log( + 'Detected plan from offerToken (Android):', + matchingOffer.basePlanId, + ); + } else { + // Fallback if we can't find the matching offer + console.log( + 'Could not map offerToken to basePlanId:', + purchaseData.offerToken, + ); + } + } + } + lastSuccessAtRef.current = Date.now(); setLastPurchase(purchase); setIsProcessing(false); @@ -607,14 +1115,14 @@ function SubscriptionFlowContainer() { } try { - await getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS); - } catch (e) { - console.warn('Failed to refresh active subscriptions:', e); - } - try { - await getAvailablePurchases(); + // Refresh both active subscriptions and available purchases after successful purchase + const [, purchases] = await Promise.all([ + getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS), + getAvailablePurchases(), + ]); + setCachedAvailablePurchases(purchases); } catch (e) { - console.warn('Failed to refresh available purchases:', e); + console.warn('Failed to refresh subscriptions:', e); } setPurchaseResult( @@ -653,24 +1161,51 @@ function SubscriptionFlowContainer() { fetchedProductsOnceRef.current = true; } - if (!loadedPurchasesOnceRef.current) { + // Fetch available purchases once when connected + if (!fetchedAvailablePurchasesRef.current) { getAvailablePurchases() - .catch((error) => { - console.warn('Failed to load available purchases:', error); + .then((purchases) => { + setCachedAvailablePurchases(purchases); + fetchedAvailablePurchasesRef.current = true; + console.log('Cached available purchases:', purchases.length); }) - .finally(() => { - loadedPurchasesOnceRef.current = true; + .catch((error) => { + console.error('Failed to fetch available purchases:', error); }); } } - }, [connected, fetchProducts, getAvailablePurchases]); + }, [connected, fetchProducts]); const handleRefreshStatus = useCallback(async () => { if (!connected || isCheckingStatus) return; setIsCheckingStatus(true); try { - await getActiveSubscriptions(); + // Refresh both active subscriptions and available purchases + const [activeSubs, purchases] = await Promise.all([ + getActiveSubscriptions(), + getAvailablePurchases(), + ]); + setCachedAvailablePurchases(purchases); + console.log('Refreshed active subscriptions:', activeSubs); + console.log('Refreshed available purchases:', purchases); + + // For iOS, check if there's a pending change + if (Platform.OS === 'ios') { + const premiumPurchases = purchases.filter( + (p) => + p.productId === 'dev.hyo.martie.premium' || + p.productId === 'dev.hyo.martie.premium_year', + ); + console.log( + 'Premium purchases found:', + premiumPurchases.map((p) => ({ + productId: p.productId, + transactionDate: new Date(p.transactionDate).toISOString(), + isAutoRenewing: p.isAutoRenewing, + })), + ); + } } catch (error) { console.error('Error checking subscription status:', error); } finally { @@ -708,8 +1243,11 @@ function SubscriptionFlowContainer() { subscriptionOffers: subscription && 'subscriptionOfferDetailsAndroid' in subscription && - subscription.subscriptionOfferDetailsAndroid - ? subscription.subscriptionOfferDetailsAndroid.map((offer) => ({ + (subscription as ProductSubscriptionAndroid) + .subscriptionOfferDetailsAndroid + ? ( + subscription as ProductSubscriptionAndroid + ).subscriptionOfferDetailsAndroid.map((offer) => ({ sku: itemId, offerToken: offer.offerToken, })) @@ -750,12 +1288,17 @@ function SubscriptionFlowContainer() { void; + hideModal: () => void; +} + +const DataModalContext = createContext( + undefined, +); + +export function DataModalProvider({children}: {children: React.ReactNode}) { + const [visible, setVisible] = useState(false); + const [data, setData] = useState(null); + const [title, setTitle] = useState('Data Details'); + const resetTimeoutRef = useRef | null>(null); + + const showData = useCallback((newData: any, newTitle?: string) => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = null; + } + setData(newData); + setTitle(newTitle || 'Data Details'); + setVisible(true); + }, []); + + const hideModal = useCallback(() => { + setVisible(false); + resetTimeoutRef.current = setTimeout(() => { + setData(null); + setTitle('Data Details'); + resetTimeoutRef.current = null; + }, 300); + }, []); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + }, []); + + const handleCopy = useCallback(() => { + if (!data) return; + + // Remove sensitive fields + const {purchaseToken, ...safeData} = data; + const jsonString = JSON.stringify(safeData, null, 2); + + Clipboard.setString(jsonString); + Alert.alert('Copied', 'Data copied to clipboard'); + }, [data]); + + return ( + + {children} + + + + + {/* Header */} + + {title} + + + + + + {/* Content */} + + + {(() => { + if (!data) return ''; + const {purchaseToken, ...safeData} = data; + return JSON.stringify(safeData, null, 2); + })()} + + + + {/* Footer */} + + + 📋 Copy JSON + + + Close + + + + + + + ); +} + +export function useDataModal() { + const context = useContext(DataModalContext); + if (!context) { + throw new Error('useDataModal must be used within DataModalProvider'); + } + return context; +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContainer: { + backgroundColor: '#fff', + borderRadius: 16, + width: '90%', + height: '75%', + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#eee', + backgroundColor: '#f8f9fa', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + }, + closeButton: { + fontSize: 24, + color: '#666', + fontWeight: '300', + }, + content: { + flex: 1, + padding: 16, + }, + jsonText: { + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + fontSize: 12, + color: '#333', + lineHeight: 18, + }, + footer: { + flexDirection: 'row', + gap: 12, + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + backgroundColor: '#f8f9fa', + }, + copyButton: { + flex: 1, + backgroundColor: '#007AFF', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + closeFooterButton: { + flex: 1, + backgroundColor: '#6c757d', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/example-expo/scripts/copy-screens.sh b/example-expo/scripts/copy-screens.sh index 5a1c105a5..171effc6f 100755 --- a/example-expo/scripts/copy-screens.sh +++ b/example-expo/scripts/copy-screens.sh @@ -32,6 +32,7 @@ add_generation_comment() { -e "s|from '\.\./src/utils/constants'|from '../constants/products'|g" \ -e "s|from '\.\./src/components/PurchaseDetails'|from '../components/PurchaseDetails'|g" \ -e "s|from '\.\./src/components/PurchaseSummaryRow'|from '../components/PurchaseSummaryRow'|g" \ + -e "s|from '\.\./src/contexts/DataModalContext'|from '../contexts/DataModalContext'|g" \ "$source_file" } > "$target_file" } diff --git a/example/App.tsx b/example/App.tsx index 930599110..5e2398913 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import AppNavigator from './navigation'; +import {DataModalProvider} from './src/contexts/DataModalContext'; // Enable debug logging for library development only (global as any).RN_IAP_DEV_MODE = true; @@ -8,7 +9,9 @@ import AppNavigator from './navigation'; function App(): React.JSX.Element { return ( - + + + ); } diff --git a/example/__tests__/screens/AvailablePurchases.test.tsx b/example/__tests__/screens/AvailablePurchases.test.tsx index 94222ec79..4f5964406 100644 --- a/example/__tests__/screens/AvailablePurchases.test.tsx +++ b/example/__tests__/screens/AvailablePurchases.test.tsx @@ -1,7 +1,9 @@ +import {type ReactElement} from 'react'; import {render, fireEvent, waitFor} from '@testing-library/react-native'; import {Alert} from 'react-native'; import AvailablePurchases from '../../screens/AvailablePurchases'; import * as RNIap from 'react-native-iap'; +import {DataModalProvider} from '../../src/contexts/DataModalContext'; // Mock functions for testing const mockGetAvailablePurchases = jest.fn(); @@ -41,6 +43,11 @@ const mockFinishTransaction = jest.fn(); jest.spyOn(Alert, 'alert'); +// Helper to render with providers +const renderWithProviders = (component: ReactElement) => { + return render({component}); +}; + describe('AvailablePurchases Screen', () => { beforeEach(() => { jest.clearAllMocks(); @@ -55,21 +62,21 @@ describe('AvailablePurchases Screen', () => { }); it.skip('renders the screen title', async () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); await waitFor(() => { expect(getByText(/Available Purchases/)).toBeTruthy(); }); }); it('shows connection status when connected', async () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); await waitFor(() => { expect(getByText('Store Connection: ✅ Connected')).toBeTruthy(); }); }); it('loads subscription products on mount', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(mockRequestProducts).toHaveBeenCalled(); @@ -77,7 +84,7 @@ describe('AvailablePurchases Screen', () => { }); it('refreshes purchases when refresh button is pressed', async () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); const refreshButton = getByText('🔄 Refresh Purchases'); fireEvent.press(refreshButton); @@ -89,14 +96,14 @@ describe('AvailablePurchases Screen', () => { }); it('displays purchase history section', () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); expect(getByText('📋 Purchase History')).toBeTruthy(); expect(getByText('dev.hyo.martie.premium')).toBeTruthy(); }); it('displays active subscriptions section', () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); expect(getByText('🔄 Active Subscriptions')).toBeTruthy(); }); @@ -106,7 +113,7 @@ describe('AvailablePurchases Screen', () => { new Error('Failed to fetch purchases'), ); - const {getByText} = render(); + const {getByText} = renderWithProviders(); const refreshButton = getByText('🔄 Refresh Purchases'); fireEvent.press(refreshButton); @@ -132,14 +139,14 @@ describe('AvailablePurchases Screen', () => { finishTransaction: mockFinishTransaction, }); - const {getByText} = render(); + const {getByText} = renderWithProviders(); await waitFor(() => { expect(getByText('No purchase history found')).toBeTruthy(); }); }); it('shows transaction details for purchases', async () => { - const {getByText} = render(); + const {getByText} = renderWithProviders(); // Check if transaction ID is displayed await waitFor(() => { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 8fed720fe..4bbea60c9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.81.1): - hermes-engine/Pre-built (= 0.81.1) - hermes-engine/Pre-built (0.81.1) - - NitroIap (14.4.9): + - NitroIap (14.4.10): - boost - DoubleConversion - fast_float @@ -2747,7 +2747,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca - NitroIap: f81705bf7ec239db334f59bb37208bf87da6c725 + NitroIap: 78912633a48b67f99272586502dd72b63a1a9971 NitroModules: 7d693306799405ca141ef5c24efc0936f20a09c0 openiap: f70e04d58c01278e4383a172a41c86e866df218c RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 diff --git a/example/screens/AvailablePurchases.tsx b/example/screens/AvailablePurchases.tsx index f5d33fd00..0520cf7da 100644 --- a/example/screens/AvailablePurchases.tsx +++ b/example/screens/AvailablePurchases.tsx @@ -8,11 +8,10 @@ import { ActivityIndicator, Alert, Platform, - Modal, } from 'react-native'; -import type {Purchase, PurchaseError} from 'react-native-iap'; +import type {PurchaseError} from 'react-native-iap'; import {useIAP, deepLinkToSubscriptions} from 'react-native-iap'; -import Clipboard from '@react-native-clipboard/clipboard'; +import {useDataModal} from '../src/contexts/DataModalContext'; // Define subscription IDs at component level like in the working example const subscriptionIds = [ @@ -22,10 +21,9 @@ const subscriptionIds = [ export default function AvailablePurchases() { const [loading, setLoading] = useState(false); const [isCheckingStatus, setIsCheckingStatus] = useState(false); - const [selectedPurchase, setSelectedPurchase] = useState( - null, - ); - const [purchaseModalVisible, setPurchaseModalVisible] = useState(false); + + // Use global modal context + const {showData} = useDataModal(); // Use the useIAP hook like subscription-flow does const { @@ -268,12 +266,7 @@ export default function AvailablePurchases() { style={styles.purchaseItem} activeOpacity={0.85} onPress={() => { - setSelectedPurchase(purchase as Purchase); - setPurchaseModalVisible(true); - }} - onLongPress={() => { - setSelectedPurchase(purchase as Purchase); - setPurchaseModalVisible(true); + showData(purchase, `Purchase: ${purchase.productId}`); }} > @@ -378,94 +371,6 @@ export default function AvailablePurchases() { 🔄 Refresh Purchases )} - - {/* Purchase Details Modal */} - setPurchaseModalVisible(false)} - > - - - - - Purchase Details - - setPurchaseModalVisible(false)}> - - - - - - {(() => { - if (!selectedPurchase) return ''; - const {purchaseToken, ...safe} = selectedPurchase || {}; - return JSON.stringify(safe, null, 2); - })()} - - - - { - if (!selectedPurchase) return; - const {purchaseToken, ...safe} = selectedPurchase || {}; - Clipboard.setString(JSON.stringify(safe, null, 2)); - Alert.alert('Copied', 'Purchase JSON copied to clipboard'); - }} - > - 📋 Copy JSON - - setPurchaseModalVisible(false)} - > - Close - - - - - ); } diff --git a/example/screens/SubscriptionFlow.tsx b/example/screens/SubscriptionFlow.tsx index b46501e2f..daa71eaae 100644 --- a/example/screens/SubscriptionFlow.tsx +++ b/example/screens/SubscriptionFlow.tsx @@ -18,6 +18,7 @@ import { getAvailablePurchases, type ActiveSubscription, type ProductSubscription, + type ProductSubscriptionAndroid, type Purchase, type PurchaseError, ErrorCode, @@ -26,26 +27,6 @@ import Loading from '../src/components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../src/utils/constants'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; -// Extended types for Android-specific properties -type AndroidSubscriptionDetails = ProductSubscription & { - subscriptionOfferDetailsAndroid?: Array<{ - basePlanId: string; - offerToken: string; - offerId?: string; - offerTags: string[]; - pricingPhases: { - pricingPhaseList: Array<{ - formattedPrice: string; - priceAmountMicros: string; - priceCurrencyCode: string; - billingPeriod: string; - billingCycleCount: number; - recurrenceMode: number; - }>; - }; - }>; -}; - type ExtendedPurchase = Purchase & { purchaseTokenAndroid?: string; dataAndroid?: { @@ -55,10 +36,17 @@ type ExtendedPurchase = Purchase & { offerToken?: string; }; +// Extended type for ActiveSubscription with additional fields that may be present +// but are not officially part of the ActiveSubscription type definition. +// These fields are either: +// - Detected/computed locally (basePlanId, _detectedBasePlanId) +// - Available in the underlying Purchase but not mapped to ActiveSubscription (isUpgradedIOS) +// - Platform-specific fields (purchaseTokenAndroid) type ExtendedActiveSubscription = ActiveSubscription & { - basePlanId?: string; - purchaseTokenAndroid?: string; - _detectedBasePlanId?: string; + basePlanId?: string; // Android: detected from subscription offers + purchaseTokenAndroid?: string; // Android: purchase token + _detectedBasePlanId?: string; // Locally detected/cached base plan ID + isUpgradedIOS?: boolean; // iOS: from PurchaseIOS.isUpgradedIOS }; // Component for plan change controls @@ -73,32 +61,31 @@ interface PlanChangeControlsProps { lastPurchasedPlan: string | null; } -const PlanChangeControls = React.memo( - ({ - activeSubscriptions, - handlePlanChange, - isProcessing, - lastPurchasedPlan, - }: PlanChangeControlsProps) => { - // Find all premium subscriptions (both monthly and yearly) - const premiumSubs = activeSubscriptions.filter( - (sub) => - sub.productId === 'dev.hyo.martie.premium' || - sub.productId === 'dev.hyo.martie.premium_year', - ); +const PlanChangeControls = React.memo(function PlanChangeControls({ + activeSubscriptions, + handlePlanChange, + isProcessing, + lastPurchasedPlan, +}: PlanChangeControlsProps) { + // Find all premium subscriptions (both monthly and yearly) + const premiumSubs = activeSubscriptions.filter( + (sub) => + sub.productId === 'dev.hyo.martie.premium' || + sub.productId === 'dev.hyo.martie.premium_year', + ); - if (premiumSubs.length === 0) return null; + if (premiumSubs.length === 0) return null; - // Detect the current plan based on product ID for iOS - let currentBasePlan = 'unknown'; - let activeSub: ActiveSubscription | undefined = undefined; + // Detect the current plan based on product ID for iOS + let currentBasePlan = 'unknown'; + let activeSub: ActiveSubscription | undefined = undefined; if (Platform.OS === 'ios') { // On iOS, find the most recent subscription (in case both exist during transition) // Sort by transaction date to get the most recent one const sortedSubs = [...premiumSubs].sort((a, b) => { - const dateA = (a as any).transactionDate || 0; - const dateB = (b as any).transactionDate || 0; + const dateA = a.transactionDate ?? 0; + const dateB = b.transactionDate ?? 0; return dateB - dateA; }); @@ -202,8 +189,7 @@ const PlanChangeControls = React.memo( )} ); - }, -); +}); /** * Subscription Flow Example - Subscription Products @@ -374,7 +360,7 @@ function SubscriptionFlow({ if (Platform.OS === 'android') { // Android subscription replacement const targetSubWithDetails = - targetSubscription as AndroidSubscriptionDetails; + targetSubscription as ProductSubscriptionAndroid; const androidOffers = targetSubWithDetails.subscriptionOfferDetailsAndroid; const targetOffer = androidOffers?.find( @@ -520,7 +506,14 @@ function SubscriptionFlow({ ], ); }, - [subscriptions, activeSubscriptions, setIsProcessing, setPurchaseResult], + [ + subscriptions, + activeSubscriptions, + setIsProcessing, + setPurchaseResult, + cachedAvailablePurchases, + setCachedAvailablePurchases, + ], ); const copyToClipboard = (subscription: ProductSubscription) => { @@ -679,8 +672,8 @@ function SubscriptionFlow({ if (premiumSubs.length > 1) { // Sort by transaction date and keep only the most recent const sortedPremiumSubs = [...premiumSubs].sort((a, b) => { - const dateA = (a as any).transactionDate || 0; - const dateB = (b as any).transactionDate || 0; + const dateA = a.transactionDate ?? 0; + const dateB = b.transactionDate ?? 0; return dateB - dateA; }); @@ -719,11 +712,12 @@ function SubscriptionFlow({ 'ActiveSubscription data:', JSON.stringify(sub, null, 2), ); + const extendedSub = sub as ExtendedActiveSubscription; console.log( 'Product ID:', sub.productId, 'Is Upgraded?:', - (sub as any).isUpgradedIOS, + extendedSub.isUpgradedIOS, ); if (Platform.OS === 'ios') { @@ -738,8 +732,8 @@ function SubscriptionFlow({ } else { // Android: Try to detect the base plan from various sources // Method 1: Check if basePlanId is directly available from native - if ((sub as any).basePlanId) { - detectedBasePlanId = (sub as any).basePlanId; + if (extendedSub.basePlanId) { + detectedBasePlanId = extendedSub.basePlanId; activeOfferLabel = detectedBasePlanId === 'premium-year' ? '📅 Yearly Plan' @@ -1047,7 +1041,7 @@ function SubscriptionFlowContainer() { } } else if (purchase.productId === 'dev.hyo.martie.premium') { // Android: Check if we have offerToken or other data to identify the plan - const purchaseData = purchase as any; + const purchaseData = purchase as ExtendedPurchase; // Log full purchase data to understand what's available console.log( @@ -1059,10 +1053,10 @@ function SubscriptionFlowContainer() { if (purchaseData.offerToken) { const premiumSub = subscriptions.find( (s) => s.id === 'dev.hyo.martie.premium', - ) as any; + ) as ProductSubscriptionAndroid; const matchingOffer = premiumSub?.subscriptionOfferDetailsAndroid?.find( - (offer: any) => offer.offerToken === purchaseData.offerToken, + (offer) => offer.offerToken === purchaseData.offerToken, ); if (matchingOffer?.basePlanId) { setLastPurchasedPlan(matchingOffer.basePlanId); @@ -1245,11 +1239,11 @@ function SubscriptionFlowContainer() { subscriptionOffers: subscription && 'subscriptionOfferDetailsAndroid' in subscription && - (subscription as AndroidSubscriptionDetails) + (subscription as ProductSubscriptionAndroid) .subscriptionOfferDetailsAndroid ? ( - subscription as AndroidSubscriptionDetails - ).subscriptionOfferDetailsAndroid!.map((offer) => ({ + subscription as ProductSubscriptionAndroid + ).subscriptionOfferDetailsAndroid.map((offer) => ({ sku: itemId, offerToken: offer.offerToken, })) diff --git a/example/src/contexts/DataModalContext.tsx b/example/src/contexts/DataModalContext.tsx new file mode 100644 index 000000000..4b699f179 --- /dev/null +++ b/example/src/contexts/DataModalContext.tsx @@ -0,0 +1,217 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useRef, + useEffect, +} from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + ScrollView, + Platform, + Alert, + StyleSheet, +} from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; + +interface DataModalContextType { + showData: (data: any, title?: string) => void; + hideModal: () => void; +} + +const DataModalContext = createContext( + undefined, +); + +export function DataModalProvider({children}: {children: React.ReactNode}) { + const [visible, setVisible] = useState(false); + const [data, setData] = useState(null); + const [title, setTitle] = useState('Data Details'); + const resetTimeoutRef = useRef | null>(null); + + const showData = useCallback((newData: any, newTitle?: string) => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = null; + } + setData(newData); + setTitle(newTitle || 'Data Details'); + setVisible(true); + }, []); + + const hideModal = useCallback(() => { + setVisible(false); + resetTimeoutRef.current = setTimeout(() => { + setData(null); + setTitle('Data Details'); + resetTimeoutRef.current = null; + }, 300); + }, []); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + }, []); + + const handleCopy = useCallback(() => { + if (!data) return; + + // Remove sensitive fields + const {purchaseToken, ...safeData} = data; + const jsonString = JSON.stringify(safeData, null, 2); + + Clipboard.setString(jsonString); + Alert.alert('Copied', 'Data copied to clipboard'); + }, [data]); + + return ( + + {children} + + + + + {/* Header */} + + {title} + + + + + + {/* Content */} + + + {(() => { + if (!data) return ''; + const {purchaseToken, ...safeData} = data; + return JSON.stringify(safeData, null, 2); + })()} + + + + {/* Footer */} + + + 📋 Copy JSON + + + Close + + + + + + + ); +} + +export function useDataModal() { + const context = useContext(DataModalContext); + if (!context) { + throw new Error('useDataModal must be used within DataModalProvider'); + } + return context; +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContainer: { + backgroundColor: '#fff', + borderRadius: 16, + width: '90%', + height: '75%', + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#eee', + backgroundColor: '#f8f9fa', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + }, + closeButton: { + fontSize: 24, + color: '#666', + fontWeight: '300', + }, + content: { + flex: 1, + padding: 16, + }, + jsonText: { + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + fontSize: 12, + color: '#333', + lineHeight: 18, + }, + footer: { + flexDirection: 'row', + gap: 12, + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + backgroundColor: '#f8f9fa', + }, + copyButton: { + flex: 1, + backgroundColor: '#007AFF', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + closeFooterButton: { + flex: 1, + backgroundColor: '#6c757d', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/ios/RnIapHelper.swift b/ios/RnIapHelper.swift index 20b110d74..1bbae7af1 100644 --- a/ios/RnIapHelper.swift +++ b/ios/RnIapHelper.swift @@ -65,6 +65,12 @@ enum RnIapHelper { if let typeIOS = dictionary["typeIOS"] as? String { product.typeIOS = typeIOS } if let familyShareable = boolValue(dictionary["isFamilyShareableIOS"]) { product.isFamilyShareableIOS = familyShareable } if let jsonRepresentation = dictionary["jsonRepresentationIOS"] as? String { product.jsonRepresentationIOS = jsonRepresentation } + if let discounts = dictionary["discountsIOS"] as? [[String: Any]] { + if let jsonData = try? JSONSerialization.data(withJSONObject: discounts, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + product.discountsIOS = jsonString + } + } if let subscriptionUnit = dictionary["subscriptionPeriodUnitIOS"] as? String { product.subscriptionPeriodUnitIOS = subscriptionUnit } if let subscriptionNumber = doubleValue(dictionary["subscriptionPeriodNumberIOS"]) { product.subscriptionPeriodNumberIOS = subscriptionNumber } if let introductoryPrice = dictionary["introductoryPriceIOS"] as? String { product.introductoryPriceIOS = introductoryPrice } @@ -98,11 +104,36 @@ enum RnIapHelper { purchase.purchaseState = state } if let isAutoRenewing = boolValue(dictionary["isAutoRenewing"]) { purchase.isAutoRenewing = isAutoRenewing } + + // iOS specific fields if let quantityIOS = doubleValue(dictionary["quantityIOS"]) { purchase.quantityIOS = quantityIOS } if let originalDate = doubleValue(dictionary["originalTransactionDateIOS"]) { purchase.originalTransactionDateIOS = originalDate } if let originalIdentifier = dictionary["originalTransactionIdentifierIOS"] as? String { purchase.originalTransactionIdentifierIOS = originalIdentifier } if let appAccountToken = dictionary["appAccountToken"] as? String { purchase.appAccountToken = appAccountToken } - + if let appBundleId = dictionary["appBundleIdIOS"] as? String { purchase.appBundleIdIOS = appBundleId } + if let countryCode = dictionary["countryCodeIOS"] as? String { purchase.countryCodeIOS = countryCode } + if let currencyCode = dictionary["currencyCodeIOS"] as? String { purchase.currencyCodeIOS = currencyCode } + if let currencySymbol = dictionary["currencySymbolIOS"] as? String { purchase.currencySymbolIOS = currencySymbol } + if let environment = dictionary["environmentIOS"] as? String { purchase.environmentIOS = environment } + if let expirationDate = doubleValue(dictionary["expirationDateIOS"]) { purchase.expirationDateIOS = expirationDate } + if let isUpgraded = boolValue(dictionary["isUpgradedIOS"]) { purchase.isUpgradedIOS = isUpgraded } + if let offer = dictionary["offerIOS"] as? [String: Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: offer, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + purchase.offerIOS = jsonString + } + } + if let ownershipType = dictionary["ownershipTypeIOS"] as? String { purchase.ownershipTypeIOS = ownershipType } + if let reason = dictionary["reasonIOS"] as? String { purchase.reasonIOS = reason } + if let reasonString = dictionary["reasonStringRepresentationIOS"] as? String { purchase.reasonStringRepresentationIOS = reasonString } + if let revocationDate = doubleValue(dictionary["revocationDateIOS"]) { purchase.revocationDateIOS = revocationDate } + if let revocationReason = dictionary["revocationReasonIOS"] as? String { purchase.revocationReasonIOS = revocationReason } + if let storefrontCountryCode = dictionary["storefrontCountryCodeIOS"] as? String { purchase.storefrontCountryCodeIOS = storefrontCountryCode } + if let subscriptionGroupId = dictionary["subscriptionGroupIdIOS"] as? String { purchase.subscriptionGroupIdIOS = subscriptionGroupId } + if let transactionReason = dictionary["transactionReasonIOS"] as? String { purchase.transactionReasonIOS = transactionReason } + if let webOrderLineItemId = dictionary["webOrderLineItemIdIOS"] as? String { purchase.webOrderLineItemIdIOS = webOrderLineItemId } + + // Android specific fields if let purchaseTokenAndroid = dictionary["purchaseTokenAndroid"] as? String { purchase.purchaseTokenAndroid = purchaseTokenAndroid } if let dataAndroid = dictionary["dataAndroid"] as? String { purchase.dataAndroid = dataAndroid } if let signatureAndroid = dictionary["signatureAndroid"] as? String { purchase.signatureAndroid = signatureAndroid } diff --git a/src/specs/RnIap.nitro.ts b/src/specs/RnIap.nitro.ts index 6f6deec93..a7a8cf706 100644 --- a/src/specs/RnIap.nitro.ts +++ b/src/specs/RnIap.nitro.ts @@ -179,6 +179,15 @@ export interface NitroReceiptValidationResultAndroid { testTransaction: ReceiptValidationResultAndroid['testTransaction']; } +/** + * Android one-time purchase offer details + */ +export interface NitroOneTimePurchaseOfferDetail { + formattedPrice: string; + priceAmountMicros: string; + priceCurrencyCode: string; +} + export interface NitroPurchase { id: PurchaseCommon['id']; productId: PurchaseCommon['productId']; @@ -188,10 +197,29 @@ export interface NitroPurchase { quantity: PurchaseCommon['quantity']; purchaseState: PurchaseCommon['purchaseState']; isAutoRenewing: PurchaseCommon['isAutoRenewing']; + // iOS specific fields quantityIOS?: number | null; originalTransactionDateIOS?: number | null; originalTransactionIdentifierIOS?: string | null; appAccountToken?: string | null; + appBundleIdIOS?: string | null; + countryCodeIOS?: string | null; + currencyCodeIOS?: string | null; + currencySymbolIOS?: string | null; + environmentIOS?: string | null; + expirationDateIOS?: number | null; + isUpgradedIOS?: boolean | null; + offerIOS?: string | null; + ownershipTypeIOS?: string | null; + reasonIOS?: string | null; + reasonStringRepresentationIOS?: string | null; + revocationDateIOS?: number | null; + revocationReasonIOS?: string | null; + storefrontCountryCodeIOS?: string | null; + subscriptionGroupIdIOS?: string | null; + transactionReasonIOS?: string | null; + webOrderLineItemIdIOS?: string | null; + // Android specific fields purchaseTokenAndroid?: string | null; dataAndroid?: string | null; signatureAndroid?: string | null; @@ -201,6 +229,7 @@ export interface NitroPurchase { packageNameAndroid?: string | null; obfuscatedAccountIdAndroid?: string | null; obfuscatedProfileIdAndroid?: string | null; + developerPayloadAndroid?: string | null; } export interface NitroProduct { @@ -217,6 +246,7 @@ export interface NitroProduct { typeIOS?: string | null; isFamilyShareableIOS?: boolean | null; jsonRepresentationIOS?: string | null; + discountsIOS?: string | null; introductoryPriceIOS?: string | null; introductoryPriceAsAmountIOS?: number | null; introductoryPriceNumberOfPeriodsIOS?: number | null; @@ -225,6 +255,7 @@ export interface NitroProduct { subscriptionPeriodNumberIOS?: number | null; subscriptionPeriodUnitIOS?: string | null; // Android specific fields + nameAndroid?: string | null; originalPriceAndroid?: string | null; originalPriceAmountMicrosAndroid?: number | null; introductoryPriceCyclesAndroid?: number | null; @@ -233,6 +264,7 @@ export interface NitroProduct { subscriptionPeriodAndroid?: string | null; freeTrialPeriodAndroid?: string | null; subscriptionOfferDetailsAndroid?: string | null; + oneTimePurchaseOfferDetailsAndroid?: NitroOneTimePurchaseOfferDetail | null; } // ╔══════════════════════════════════════════════════════════════════════════╗ diff --git a/src/utils/type-bridge.ts b/src/utils/type-bridge.ts index 76db2dc2c..9b9adb4f6 100644 --- a/src/utils/type-bridge.ts +++ b/src/utils/type-bridge.ts @@ -220,48 +220,57 @@ export function convertNitroProductToProduct( const iosProduct: any = { ...base, displayNameIOS: nitroProduct.displayName ?? nitroProduct.title, - isFamilyShareableIOS: Boolean( - (nitroProduct as any).isFamilyShareableIOS ?? false, - ), + isFamilyShareableIOS: Boolean(nitroProduct.isFamilyShareableIOS ?? false), jsonRepresentationIOS: - (nitroProduct as any).jsonRepresentationIOS ?? DEFAULT_JSON_REPR, - typeIOS: normalizeProductTypeIOS((nitroProduct as any).typeIOS), + nitroProduct.jsonRepresentationIOS ?? DEFAULT_JSON_REPR, + typeIOS: normalizeProductTypeIOS(nitroProduct.typeIOS), subscriptionInfoIOS: undefined, }; iosProduct.introductoryPriceAsAmountIOS = toNullableString( - (nitroProduct as any).introductoryPriceAsAmountIOS, + nitroProduct.introductoryPriceAsAmountIOS, ); iosProduct.introductoryPriceIOS = toNullableString( - (nitroProduct as any).introductoryPriceIOS, + nitroProduct.introductoryPriceIOS, ); iosProduct.introductoryPriceNumberOfPeriodsIOS = toNullableString( - (nitroProduct as any).introductoryPriceNumberOfPeriodsIOS, + nitroProduct.introductoryPriceNumberOfPeriodsIOS, ); iosProduct.introductoryPricePaymentModeIOS = normalizePaymentMode( - (nitroProduct as any).introductoryPricePaymentModeIOS, + nitroProduct.introductoryPricePaymentModeIOS, ); iosProduct.introductoryPriceSubscriptionPeriodIOS = normalizeSubscriptionPeriod( - (nitroProduct as any).introductoryPriceSubscriptionPeriodIOS, + nitroProduct.introductoryPriceSubscriptionPeriodIOS, ); iosProduct.subscriptionPeriodNumberIOS = toNullableString( - (nitroProduct as any).subscriptionPeriodNumberIOS, + nitroProduct.subscriptionPeriodNumberIOS, ); iosProduct.subscriptionPeriodUnitIOS = normalizeSubscriptionPeriod( - (nitroProduct as any).subscriptionPeriodUnitIOS, + nitroProduct.subscriptionPeriodUnitIOS, ); + // Parse discountsIOS from JSON string if present + if (nitroProduct.discountsIOS) { + try { + iosProduct.discountsIOS = JSON.parse(nitroProduct.discountsIOS); + } catch { + iosProduct.discountsIOS = null; + } + } else { + iosProduct.discountsIOS = null; + } + return iosProduct as Product; } const androidProduct: any = { ...base, - nameAndroid: (nitroProduct as any).nameAndroid ?? nitroProduct.title, - oneTimePurchaseOfferDetailsAndroid: (nitroProduct as any) - .oneTimePurchaseOfferDetailsAndroid, + nameAndroid: nitroProduct.nameAndroid ?? nitroProduct.title, + oneTimePurchaseOfferDetailsAndroid: + nitroProduct.oneTimePurchaseOfferDetailsAndroid ?? null, subscriptionOfferDetailsAndroid: parseSubscriptionOffers( - (nitroProduct as any).subscriptionOfferDetailsAndroid, + nitroProduct.subscriptionOfferDetailsAndroid, ), }; @@ -340,6 +349,54 @@ export function convertNitroPurchaseToPurchase( iosPurchase.appAccountToken = toNullableString( nitroPurchase.appAccountToken, ); + iosPurchase.appBundleIdIOS = toNullableString(nitroPurchase.appBundleIdIOS); + iosPurchase.countryCodeIOS = toNullableString(nitroPurchase.countryCodeIOS); + iosPurchase.currencyCodeIOS = toNullableString( + nitroPurchase.currencyCodeIOS, + ); + iosPurchase.currencySymbolIOS = toNullableString( + nitroPurchase.currencySymbolIOS, + ); + iosPurchase.environmentIOS = toNullableString(nitroPurchase.environmentIOS); + iosPurchase.expirationDateIOS = toNullableNumber( + nitroPurchase.expirationDateIOS, + ); + iosPurchase.isUpgradedIOS = toNullableBoolean(nitroPurchase.isUpgradedIOS); + // Parse offerIOS from JSON string if present + if (nitroPurchase.offerIOS) { + try { + iosPurchase.offerIOS = JSON.parse(nitroPurchase.offerIOS); + } catch { + iosPurchase.offerIOS = null; + } + } else { + iosPurchase.offerIOS = null; + } + iosPurchase.ownershipTypeIOS = toNullableString( + nitroPurchase.ownershipTypeIOS, + ); + iosPurchase.reasonIOS = toNullableString(nitroPurchase.reasonIOS); + iosPurchase.reasonStringRepresentationIOS = toNullableString( + nitroPurchase.reasonStringRepresentationIOS, + ); + iosPurchase.revocationDateIOS = toNullableNumber( + nitroPurchase.revocationDateIOS, + ); + iosPurchase.revocationReasonIOS = toNullableString( + nitroPurchase.revocationReasonIOS, + ); + iosPurchase.storefrontCountryCodeIOS = toNullableString( + nitroPurchase.storefrontCountryCodeIOS, + ); + iosPurchase.subscriptionGroupIdIOS = toNullableString( + nitroPurchase.subscriptionGroupIdIOS, + ); + iosPurchase.transactionReasonIOS = toNullableString( + nitroPurchase.transactionReasonIOS, + ); + iosPurchase.webOrderLineItemIdIOS = toNullableString( + nitroPurchase.webOrderLineItemIdIOS, + ); return iosPurchase as Purchase; } @@ -363,6 +420,9 @@ export function convertNitroPurchaseToPurchase( androidPurchase.obfuscatedProfileIdAndroid = toNullableString( nitroPurchase.obfuscatedProfileIdAndroid, ); + androidPurchase.developerPayloadAndroid = toNullableString( + nitroPurchase.developerPayloadAndroid, + ); return androidPurchase as Purchase; }