@umituz/react-native-subscription 2.27.112 → 2.27.114

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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/application/CreditsInitializer.ts +28 -125
  3. package/src/domains/credits/application/credit-strategies/{CreditAllocationContext.ts → CreditAllocationOrchestrator.ts} +4 -9
  4. package/src/domains/credits/application/creditDocumentHelpers.ts +58 -0
  5. package/src/domains/credits/application/creditOperationUtils.ts +154 -0
  6. package/src/domains/credits/core/CreditsMapper.ts +8 -13
  7. package/src/domains/credits/infrastructure/{CreditsRepositoryProvider.ts → CreditsRepositoryManager.ts} +2 -2
  8. package/src/domains/credits/presentation/useCredits.ts +2 -3
  9. package/src/domains/credits/presentation/useDeductCredit.ts +4 -4
  10. package/src/domains/paywall/components/PaywallContainer.types.ts +1 -1
  11. package/src/domains/paywall/components/PaywallModal.tsx +28 -52
  12. package/src/domains/paywall/hooks/usePaywallActions.ts +77 -33
  13. package/src/domains/subscription/application/SubscriptionInitializer.ts +1 -1
  14. package/src/domains/subscription/application/SubscriptionSyncService.ts +17 -21
  15. package/src/domains/subscription/core/RevenueCatError.ts +40 -31
  16. package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +0 -1
  17. package/src/domains/subscription/infrastructure/hooks/useRevenueCatTrialEligibility.ts +19 -85
  18. package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +33 -75
  19. package/src/domains/subscription/infrastructure/managers/subscriptionManagerUtils.ts +57 -0
  20. package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +3 -12
  21. package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +0 -2
  22. package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.ts +2 -4
  23. package/src/domains/subscription/infrastructure/services/RevenueCatService.ts +1 -5
  24. package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -12
  25. package/src/domains/subscription/infrastructure/utils/authPurchaseState.ts +69 -0
  26. package/src/domains/subscription/infrastructure/utils/trialEligibilityUtils.ts +77 -0
  27. package/src/domains/subscription/presentation/components/feedback/FeedbackOption.tsx +139 -0
  28. package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackModal.tsx +15 -70
  29. package/src/domains/subscription/presentation/components/feedback/paywallFeedbackStyles.ts +0 -92
  30. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
  31. package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +1 -18
  32. package/src/domains/subscription/presentation/useAuthAwarePurchase.ts +19 -69
  33. package/src/domains/subscription/presentation/usePaywallVisibility.ts +1 -1
  34. package/src/domains/subscription/presentation/usePremium.ts +2 -11
  35. package/src/domains/subscription/presentation/useSubscriptionStatus.ts +1 -6
  36. package/src/domains/trial/application/TrialService.ts +4 -8
  37. package/src/domains/wallet/index.ts +0 -6
  38. package/src/domains/wallet/infrastructure/repositories/TransactionRepository.ts +1 -1
  39. package/src/domains/wallet/infrastructure/services/ProductMetadataService.ts +0 -13
  40. package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +0 -10
  41. package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +0 -8
  42. package/src/domains/wallet/presentation/screens/WalletScreen.tsx +57 -43
  43. package/src/index.ts +1 -1
  44. package/src/init/createSubscriptionInitModule.ts +1 -4
  45. package/src/presentation/hooks/feedback/useFeedbackSubmit.ts +0 -14
  46. package/src/shared/application/ActivationHandler.ts +6 -6
  47. package/src/shared/application/FeedbackService.ts +0 -21
  48. package/src/shared/infrastructure/SubscriptionEventBus.ts +1 -2
  49. package/src/shared/presentation/index.ts +1 -0
  50. package/src/shared/presentation/layouts/ScreenLayout.tsx +79 -0
  51. package/src/shared/types/CommonTypes.ts +65 -0
  52. package/src/shared/utils/BaseError.ts +26 -0
  53. package/src/shared/utils/Logger.ts +15 -46
  54. package/src/shared/utils/Result.ts +16 -0
  55. package/src/shared/utils/SubscriptionConfig.ts +1 -1
  56. package/src/shared/utils/SubscriptionError.ts +20 -30
  57. package/src/utils/appUtils.ts +34 -0
  58. package/src/utils/dateUtils.ts +32 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/packageTypeDetector.ts +0 -4
  61. package/src/domains/wallet/presentation/screens/WalletScreenContainer.tsx +0 -88
@@ -2,9 +2,10 @@
2
2
  * Paywall Modal
3
3
  */
4
4
 
5
- import React, { useState, useCallback, useEffect } from "react";
6
- import { View, ScrollView, TouchableOpacity, Linking, type ImageSourcePropType } from "react-native";
7
- import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system";
5
+ import React, { useCallback, useEffect } from "react";
6
+ import { View, TouchableOpacity, Linking, type ImageSourcePropType } from "react-native";
7
+ import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner, useSafeAreaInsets } from "@umituz/react-native-design-system";
8
+ import { ScreenLayout } from "../../../shared/presentation";
8
9
  import { Image } from "expo-image";
9
10
  import type { PurchasesPackage } from "react-native-purchases";
10
11
  import { PlanCard } from "./PlanCard";
@@ -12,7 +13,7 @@ import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from
12
13
  import { paywallModalStyles as styles } from "./PaywallModal.styles";
13
14
  import { PaywallFeatures } from "./PaywallFeatures";
14
15
  import { PaywallFooter } from "./PaywallFooter";
15
- import { usePurchaseLoadingStore, selectIsPurchasing } from "../../subscription/presentation/stores";
16
+ import { usePaywallActions } from "../hooks/usePaywallActions";
16
17
 
17
18
  /** Trial eligibility info per product */
18
19
  export interface TrialEligibilityInfo {
@@ -47,60 +48,28 @@ export interface PaywallModalProps {
47
48
  export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
48
49
  const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore, trialEligibility = {}, trialSubtitleText } = props;
49
50
  const tokens = useAppDesignTokens();
50
- const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
51
- const [isLocalProcessing, setIsLocalProcessing] = useState(false);
52
-
53
- // Global purchase loading state (for auto-execution after auth)
54
- const isGlobalPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
55
- const { startPurchase, endPurchase } = usePurchaseLoadingStore();
51
+ const insets = useSafeAreaInsets();
52
+
53
+ const {
54
+ selectedPlanId,
55
+ setSelectedPlanId,
56
+ isProcessing,
57
+ handlePurchase,
58
+ handleRestore,
59
+ resetState,
60
+ } = usePaywallActions({ packages, onPurchase, onRestore });
56
61
 
57
62
  // Reset selected plan when packages change
58
63
  useEffect(() => {
59
64
  setSelectedPlanId(null);
60
- }, [packages]);
65
+ }, [packages, setSelectedPlanId]);
61
66
 
62
67
  // Cleanup state when modal closes to prevent stale state
63
68
  useEffect(() => {
64
69
  if (!visible) {
65
- setSelectedPlanId(null);
66
- setIsLocalProcessing(false);
67
- }
68
- }, [visible]);
69
-
70
- // Combined processing state
71
- const isProcessing = isLocalProcessing || isGlobalPurchasing;
72
-
73
- const handlePurchase = useCallback(async () => {
74
- if (!selectedPlanId || !onPurchase) return;
75
-
76
- setIsLocalProcessing(true);
77
- startPurchase(selectedPlanId, "manual");
78
-
79
- try {
80
- const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
81
- if (pkg) {
82
- await onPurchase(pkg);
83
- }
84
- } catch (error) {
85
- if (__DEV__) {
86
- console.error("[PaywallModal] Purchase failed:", error);
87
- }
88
- } finally {
89
- setIsLocalProcessing(false);
90
- endPurchase();
91
- }
92
- }, [selectedPlanId, packages, onPurchase, startPurchase, endPurchase]);
93
-
94
- const handleRestore = useCallback(async () => {
95
- if (!onRestore || isProcessing) return;
96
-
97
- setIsLocalProcessing(true);
98
- try {
99
- await onRestore();
100
- } finally {
101
- setIsLocalProcessing(false);
70
+ resetState();
102
71
  }
103
- }, [onRestore, isProcessing]);
72
+ }, [visible, resetState]);
104
73
 
105
74
  const handleLegalUrl = useCallback(async (url: string | undefined) => {
106
75
  if (!url) return;
@@ -110,11 +79,18 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
110
79
  return (
111
80
  <BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
112
81
  <View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
113
- <TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
82
+ <TouchableOpacity
83
+ onPress={onClose}
84
+ style={[
85
+ styles.closeBtn,
86
+ { backgroundColor: tokens.colors.surfaceSecondary, top: Math.max(insets.top, 12) }
87
+ ]}
88
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
89
+ >
114
90
  <AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
115
91
  </TouchableOpacity>
116
92
 
117
- <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
93
+ <ScreenLayout scrollable={true} edges={["bottom"]} backgroundColor="transparent" contentContainerStyle={styles.scroll}>
118
94
  {heroImage && (
119
95
  <View style={styles.heroContainer}>
120
96
  <Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
@@ -171,7 +147,7 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
171
147
  </TouchableOpacity>
172
148
 
173
149
  <PaywallFooter translations={translations} legalUrls={legalUrls} isProcessing={isProcessing} onRestore={onRestore ? handleRestore : undefined} onLegalClick={handleLegalUrl} />
174
- </ScrollView>
150
+ </ScreenLayout>
175
151
  </View>
176
152
  </BaseModal>
177
153
  );
@@ -1,55 +1,99 @@
1
- import { useCallback } from "react";
1
+ /**
2
+ * usePaywallActions Hook
3
+ * Encapsulates purchase and restore flow for the paywall.
4
+ */
5
+ import { useState, useCallback } from "react";
2
6
  import type { PurchasesPackage } from "react-native-purchases";
3
- import { useRestorePurchase } from "../../subscription/infrastructure/hooks/useRestorePurchase";
4
- import { useAuthAwarePurchase } from "../../subscription/presentation/useAuthAwarePurchase";
7
+ import { usePurchaseLoadingStore } from "../../subscription/presentation/stores";
5
8
  import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
6
9
 
7
- interface UsePaywallActionsProps {
10
+ export interface UsePaywallActionsParams {
11
+ packages?: PurchasesPackage[];
12
+ onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
13
+ onRestore?: () => Promise<void | boolean>;
8
14
  source?: PurchaseSource;
9
15
  onPurchaseSuccess?: () => void;
10
- onPurchaseError?: (error: string) => void;
16
+ onPurchaseError?: (error: Error | string) => void;
11
17
  onAuthRequired?: () => void;
12
- onClose: () => void;
18
+ onClose?: () => void;
13
19
  }
14
20
 
15
- export const usePaywallActions = ({
16
- source,
21
+ export function usePaywallActions({
22
+ packages = [],
23
+ onPurchase,
24
+ onRestore,
17
25
  onPurchaseSuccess,
18
26
  onPurchaseError,
19
- onAuthRequired: _onAuthRequired,
27
+ onAuthRequired,
20
28
  onClose,
21
- }: UsePaywallActionsProps) => {
22
- const { handlePurchase: authAwarePurchase } = useAuthAwarePurchase({ source });
23
- const { mutateAsync: restorePurchases } = useRestorePurchase();
29
+ }: UsePaywallActionsParams) {
30
+ const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
31
+ const [isLocalProcessing, setIsLocalProcessing] = useState(false);
32
+
33
+ const { startPurchase, endPurchase } = usePurchaseLoadingStore();
34
+ const isGlobalPurchasing = usePurchaseLoadingStore((state) => state.isPurchasing);
35
+
36
+ const isProcessing = isLocalProcessing || isGlobalPurchasing;
37
+
38
+ const handlePurchase = useCallback(async () => {
39
+ // If no plan selected, use the first available one as fallback or return
40
+ const planId = selectedPlanId || (packages.length > 0 ? packages[0]?.product.identifier : null);
41
+
42
+ if (!planId || !onPurchase || isProcessing) {
43
+ if (!planId && onAuthRequired) onAuthRequired();
44
+ return;
45
+ }
46
+
47
+ setIsLocalProcessing(true);
48
+ // Map PurchaseSource to store's expected "manual" | "auto-execution"
49
+ startPurchase(planId, "manual");
24
50
 
25
- const handlePurchase = useCallback(async (pkg: PurchasesPackage) => {
26
51
  try {
27
- if (typeof __DEV__ !== "undefined" && __DEV__) {
28
- console.log("[PaywallActions] Purchase started:", pkg.product.identifier);
52
+ const pkg = packages.find((p) => p.product.identifier === planId);
53
+ if (pkg) {
54
+ const success = await onPurchase(pkg);
55
+ if (success !== false) {
56
+ onPurchaseSuccess?.();
57
+ onClose?.(); // Close on success if provided
58
+ }
29
59
  }
30
- const res = await authAwarePurchase(pkg, source);
31
- if (res) {
32
- onPurchaseSuccess?.();
33
- onClose();
34
- }
35
- } catch (err: unknown) {
36
- const message = err instanceof Error ? err.message : String(err);
37
- onPurchaseError?.(message);
60
+ } catch (error) {
61
+ const err = error instanceof Error ? error : new Error(String(error));
62
+ onPurchaseError?.(err);
63
+ } finally {
64
+ setIsLocalProcessing(false);
65
+ endPurchase();
38
66
  }
39
- }, [authAwarePurchase, source, onClose, onPurchaseSuccess, onPurchaseError]);
67
+ }, [selectedPlanId, packages, onPurchase, isProcessing, startPurchase, endPurchase, onPurchaseSuccess, onPurchaseError, onAuthRequired, onClose]);
40
68
 
41
69
  const handleRestore = useCallback(async () => {
70
+ if (!onRestore || isProcessing) return;
71
+
72
+ setIsLocalProcessing(true);
42
73
  try {
43
- const res = await restorePurchases();
44
- if (res.success) {
74
+ const success = await onRestore();
75
+ if (success !== false) {
45
76
  onPurchaseSuccess?.();
46
- onClose();
47
77
  }
48
- } catch (err: unknown) {
49
- const message = err instanceof Error ? err.message : String(err);
50
- onPurchaseError?.(message);
78
+ } catch (error) {
79
+ const err = error instanceof Error ? error : new Error(String(error));
80
+ onPurchaseError?.(err);
81
+ } finally {
82
+ setIsLocalProcessing(false);
51
83
  }
52
- }, [restorePurchases, onClose, onPurchaseSuccess, onPurchaseError]);
84
+ }, [onRestore, isProcessing, onPurchaseSuccess, onPurchaseError]);
53
85
 
54
- return { handlePurchase, handleRestore };
55
- };
86
+ const resetState = useCallback(() => {
87
+ setSelectedPlanId(null);
88
+ setIsLocalProcessing(false);
89
+ }, []);
90
+
91
+ return {
92
+ selectedPlanId,
93
+ setSelectedPlanId,
94
+ isProcessing,
95
+ handlePurchase,
96
+ handleRestore,
97
+ resetState,
98
+ };
99
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { Platform } from "react-native";
10
- import { configureCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryProvider";
10
+ import { configureCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
11
11
  import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
12
12
  import { configureAuthProvider } from "../presentation/useAuthAwarePurchase";
13
13
  import { SubscriptionSyncService } from "./SubscriptionSyncService";
@@ -1,7 +1,7 @@
1
1
  import type { CustomerInfo } from "react-native-purchases";
2
2
  import type { RevenueCatData } from "../core/RevenueCatData";
3
3
  import { type PeriodType, type PurchaseSource, PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
4
- import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryProvider";
4
+ import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
5
5
  import { extractRevenueCatData } from "./SubscriptionSyncUtils";
6
6
  import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
7
7
 
@@ -15,24 +15,22 @@ export class SubscriptionSyncService {
15
15
  async handlePurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource) {
16
16
  try {
17
17
  const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
18
- const purchaseId = revenueCatData.originalTransactionId
18
+ const purchaseId = revenueCatData.originalTransactionId
19
19
  ? `purchase_${revenueCatData.originalTransactionId}`
20
20
  : `purchase_${productId}_${Date.now()}`;
21
21
 
22
22
  await getCreditsRepository().initializeCredits(
23
- userId,
24
- purchaseId,
25
- productId,
26
- source ?? PURCHASE_SOURCE.SETTINGS, // Default to settings if source unknown
23
+ userId,
24
+ purchaseId,
25
+ productId,
26
+ source ?? PURCHASE_SOURCE.SETTINGS,
27
27
  revenueCatData,
28
- PURCHASE_TYPE.INITIAL // Default to INITIAL
28
+ PURCHASE_TYPE.INITIAL
29
29
  );
30
-
31
- // Notify listeners via Event Bus
30
+
32
31
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
33
32
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, { userId, productId });
34
- } catch (error) {
35
- if (__DEV__) console.error('[SubscriptionSyncService] Credits init failed:', error);
33
+ } catch {
36
34
  }
37
35
  }
38
36
 
@@ -40,23 +38,22 @@ export class SubscriptionSyncService {
40
38
  try {
41
39
  const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
42
40
  revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
43
- const purchaseId = revenueCatData.originalTransactionId
41
+ const purchaseId = revenueCatData.originalTransactionId
44
42
  ? `renewal_${revenueCatData.originalTransactionId}_${newExpirationDate}`
45
43
  : `renewal_${productId}_${Date.now()}`;
46
44
 
47
45
  await getCreditsRepository().initializeCredits(
48
- userId,
49
- purchaseId,
50
- productId,
51
- PURCHASE_SOURCE.RENEWAL,
46
+ userId,
47
+ purchaseId,
48
+ productId,
49
+ PURCHASE_SOURCE.RENEWAL,
52
50
  revenueCatData,
53
51
  PURCHASE_TYPE.RENEWAL
54
52
  );
55
-
53
+
56
54
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
57
55
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, { userId, productId });
58
- } catch (error) {
59
- if (__DEV__) console.error('[SubscriptionSyncService] Renewal credits init failed:', error);
56
+ } catch {
60
57
  }
61
58
  }
62
59
 
@@ -123,8 +120,7 @@ export class SubscriptionSyncService {
123
120
 
124
121
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
125
122
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
126
- } catch (error) {
127
- if (__DEV__) console.error('[SubscriptionSyncService] Premium status sync failed:', error);
123
+ } catch {
128
124
  }
129
125
  }
130
126
  }
@@ -3,54 +3,63 @@
3
3
  * Domain-specific error types for RevenueCat operations
4
4
  */
5
5
 
6
- export class RevenueCatError extends Error {
7
- constructor(message: string) {
8
- super(message);
9
- this.name = "RevenueCatError";
10
- }
6
+ import { BaseError } from "../../../shared/utils/BaseError";
7
+
8
+ export class RevenueCatError extends BaseError {
9
+ constructor(message: string, code: string = 'REVENUE_CAT_ERROR', cause?: Error) {
10
+ super(message, code, cause);
11
+ this.name = "RevenueCatError";
12
+ }
11
13
  }
12
14
 
13
15
  export class RevenueCatInitializationError extends RevenueCatError {
14
- constructor(message = "RevenueCat service is not initialized") {
15
- super(message);
16
- this.name = "RevenueCatInitializationError";
17
- }
16
+ constructor(message = "RevenueCat service is not initialized", cause?: Error) {
17
+ super(message, 'REVENUE_CAT_NOT_INITIALIZED', cause);
18
+ this.name = "RevenueCatInitializationError";
19
+ }
18
20
  }
19
21
 
20
22
  export class RevenueCatConfigurationError extends RevenueCatError {
21
- constructor(message = "RevenueCat configuration is invalid") {
22
- super(message);
23
- this.name = "RevenueCatConfigurationError";
24
- }
23
+ constructor(message = "RevenueCat configuration is invalid", cause?: Error) {
24
+ super(message, 'REVENUE_CAT_CONFIGURATION_ERROR', cause);
25
+ this.name = "RevenueCatConfigurationError";
26
+ }
25
27
  }
26
28
 
27
29
  export class RevenueCatPurchaseError extends RevenueCatError {
28
- public productId: string | undefined;
30
+ public readonly productId: string | undefined;
31
+
32
+ constructor(message: string, productId?: string, cause?: Error) {
33
+ super(message, 'REVENUE_CAT_PURCHASE_ERROR', cause);
34
+ this.name = "RevenueCatPurchaseError";
35
+ this.productId = productId;
36
+ }
29
37
 
30
- constructor(message: string, productId?: string) {
31
- super(message);
32
- this.name = "RevenueCatPurchaseError";
33
- this.productId = productId;
34
- }
38
+ override toJSON() {
39
+ return {
40
+ ...super.toJSON(),
41
+ productId: this.productId,
42
+ };
43
+ }
35
44
  }
36
45
 
37
46
  export class RevenueCatRestoreError extends RevenueCatError {
38
- constructor(message = "Failed to restore purchases") {
39
- super(message);
40
- this.name = "RevenueCatRestoreError";
41
- }
47
+ constructor(message = "Failed to restore purchases", cause?: Error) {
48
+ super(message, 'REVENUE_CAT_RESTORE_ERROR', cause);
49
+ this.name = "RevenueCatRestoreError";
50
+ }
42
51
  }
43
52
 
44
53
  export class RevenueCatNetworkError extends RevenueCatError {
45
- constructor(message = "Network error during RevenueCat operation") {
46
- super(message);
47
- this.name = "RevenueCatNetworkError";
48
- }
54
+ constructor(message = "Network error during RevenueCat operation", cause?: Error) {
55
+ super(message, 'REVENUE_CAT_NETWORK_ERROR', cause);
56
+ this.name = "RevenueCatNetworkError";
57
+ }
49
58
  }
50
59
 
51
60
  export class RevenueCatExpoGoError extends RevenueCatError {
52
- constructor(message = "RevenueCat is not available in Expo Go. Use a development build or test store.") {
53
- super(message);
54
- this.name = "RevenueCatExpoGoError";
55
- }
61
+ constructor(message = "RevenueCat is not available in Expo Go. Use a development build or test store.", cause?: Error) {
62
+ super(message, 'REVENUE_CAT_EXPO_GO_ERROR', cause);
63
+ this.name = "RevenueCatExpoGoError";
64
+ }
56
65
  }
@@ -44,7 +44,6 @@ export class PackageHandler {
44
44
 
45
45
  return packages;
46
46
  } catch (error) {
47
- // Re-throw with more context
48
47
  throw new Error(
49
48
  `Failed to fetch subscription packages. ${
50
49
  error instanceof Error ? error.message : "Unknown error"
@@ -5,24 +5,16 @@
5
5
  */
6
6
 
7
7
  import { useState, useEffect, useCallback, useRef } from "react";
8
- import Purchases, {
9
- type IntroEligibility,
10
- INTRO_ELIGIBILITY_STATUS,
11
- } from "react-native-purchases";
12
- import { getRevenueCatService } from "../../infrastructure/services/RevenueCatService";
8
+ import { getRevenueCatService } from "../services/RevenueCatService";
9
+ import {
10
+ checkTrialEligibility,
11
+ createFallbackEligibilityMap,
12
+ hasAnyEligibleTrial,
13
+ type ProductTrialEligibility,
14
+ type TrialEligibilityMap,
15
+ } from "../utils/trialEligibilityUtils";
13
16
 
14
- /** Trial eligibility info for a single product */
15
- export interface ProductTrialEligibility {
16
- /** Product identifier */
17
- productId: string;
18
- /** Whether eligible for introductory offer (free trial) */
19
- eligible: boolean;
20
- /** Trial duration in days (if available from product) */
21
- trialDurationDays?: number;
22
- }
23
-
24
- /** Map of product ID to eligibility */
25
- export type TrialEligibilityMap = Record<string, ProductTrialEligibility>;
17
+ export type { ProductTrialEligibility, TrialEligibilityMap };
26
18
 
27
19
  export interface UseRevenueCatTrialEligibilityResult {
28
20
  /** Map of product IDs to their trial eligibility */
@@ -37,17 +29,6 @@ export interface UseRevenueCatTrialEligibilityResult {
37
29
  getProductEligibility: (productId: string) => ProductTrialEligibility | null;
38
30
  }
39
31
 
40
- /** Cache duration in milliseconds (5 minutes) */
41
- const CACHE_DURATION_MS = 5 * 60 * 1000;
42
-
43
- /** Cached eligibility result */
44
- interface CachedEligibility {
45
- data: TrialEligibilityMap;
46
- timestamp: number;
47
- }
48
-
49
- let eligibilityCache: CachedEligibility | null = null;
50
-
51
32
  /**
52
33
  * Hook to check trial eligibility via RevenueCat
53
34
  * Uses Apple's introductory offer eligibility system
@@ -56,6 +37,7 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
56
37
  const [eligibilityMap, setEligibilityMap] = useState<TrialEligibilityMap>({});
57
38
  const [isLoading, setIsLoading] = useState(false);
58
39
  const isMountedRef = useRef(true);
40
+ const currentRequestRef = useRef<number | null>(null);
59
41
 
60
42
  useEffect(() => {
61
43
  isMountedRef.current = true;
@@ -69,69 +51,29 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
69
51
  return;
70
52
  }
71
53
 
72
- // Check cache validity
73
- if (
74
- eligibilityCache &&
75
- Date.now() - eligibilityCache.timestamp < CACHE_DURATION_MS
76
- ) {
77
- const allCached = productIds.every(
78
- (id) => eligibilityCache?.data[id] !== undefined
79
- );
80
- if (allCached && isMountedRef.current) {
81
- setEligibilityMap(eligibilityCache.data);
82
- return;
83
- }
84
- }
85
-
86
54
  const service = getRevenueCatService();
87
55
  if (!service || !service.isInitialized()) {
88
56
  return;
89
57
  }
90
58
 
59
+ const requestId = Date.now();
60
+ currentRequestRef.current = requestId;
91
61
  setIsLoading(true);
92
62
 
93
63
  try {
94
- const eligibilities: Record<string, IntroEligibility> =
95
- await Purchases.checkTrialOrIntroductoryPriceEligibility(productIds);
96
-
97
- const newMap: TrialEligibilityMap = {};
64
+ const newMap = await checkTrialEligibility(productIds);
98
65
 
99
- for (const productId of productIds) {
100
- const eligibility = eligibilities[productId];
101
- const isEligible =
102
- eligibility?.status === INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE;
103
-
104
- newMap[productId] = {
105
- productId,
106
- eligible: isEligible,
107
- trialDurationDays: 7, // Default to 7 days as configured in App Store Connect
108
- };
109
- }
110
-
111
- // Update cache
112
- eligibilityCache = {
113
- data: { ...eligibilityCache?.data, ...newMap },
114
- timestamp: Date.now(),
115
- };
116
-
117
- if (isMountedRef.current) {
66
+ if (isMountedRef.current && currentRequestRef.current === requestId) {
118
67
  setEligibilityMap((prev) => ({ ...prev, ...newMap }));
119
68
  }
120
69
  } catch {
121
- // On error, default to eligible (better UX)
122
- const fallbackMap: TrialEligibilityMap = {};
123
- for (const productId of productIds) {
124
- fallbackMap[productId] = {
125
- productId,
126
- eligible: true,
127
- trialDurationDays: 7,
128
- };
129
- }
130
- if (isMountedRef.current) {
70
+ const fallbackMap = createFallbackEligibilityMap(productIds);
71
+
72
+ if (isMountedRef.current && currentRequestRef.current === requestId) {
131
73
  setEligibilityMap((prev) => ({ ...prev, ...fallbackMap }));
132
74
  }
133
75
  } finally {
134
- if (isMountedRef.current) {
76
+ if (isMountedRef.current && currentRequestRef.current === requestId) {
135
77
  setIsLoading(false);
136
78
  }
137
79
  }
@@ -144,9 +86,7 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
144
86
  [eligibilityMap]
145
87
  );
146
88
 
147
- const hasEligibleTrial = Object.values(eligibilityMap).some(
148
- (e) => e.eligible
149
- );
89
+ const hasEligibleTrial = hasAnyEligibleTrial(eligibilityMap);
150
90
 
151
91
  return {
152
92
  eligibilityMap,
@@ -157,9 +97,3 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
157
97
  };
158
98
  }
159
99
 
160
- /**
161
- * Clear eligibility cache (useful for testing)
162
- */
163
- export function clearTrialEligibilityCache(): void {
164
- eligibilityCache = null;
165
- }