@umituz/react-native-subscription 2.43.6 → 2.43.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.43.6",
3
+ "version": "2.43.8",
4
4
  "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,39 +1,32 @@
1
- import { useCallback, useRef } from "react";
2
- import { useMutation, useQueryClient } from "@umituz/react-native-design-system/tanstack";
1
+ import { useCallback, useState } from "react";
3
2
  import { getCreditsRepository } from "../../infrastructure/CreditsRepositoryManager";
4
3
  import type { UseDeductCreditParams, UseDeductCreditResult } from "./types";
5
- import type { DeductCreditsResult } from "../../core/Credits";
6
- import { createDeductCreditMutationConfig, type MutationContext } from "./mutationConfig";
7
4
 
8
5
  export const useDeductCredit = ({
9
6
  userId,
10
7
  onCreditsExhausted,
11
8
  }: UseDeductCreditParams): UseDeductCreditResult => {
12
9
  const repository = getCreditsRepository();
13
- const queryClient = useQueryClient();
14
-
15
- const mutation = useMutation<DeductCreditsResult, Error, number, MutationContext>(
16
- createDeductCreditMutationConfig(userId, repository, queryClient)
17
- );
18
-
19
- // Use ref for stable reference to mutateAsync — avoids re-creating callbacks every render
20
- const mutateAsyncRef = useRef(mutation.mutateAsync);
21
- mutateAsyncRef.current = mutation.mutateAsync;
10
+ const [isDeducting, setIsDeducting] = useState(false);
22
11
 
23
12
  const deductCredit = useCallback(async (cost: number = 1): Promise<boolean> => {
24
- if (__DEV__) console.log('[useDeductCredit] >>> deductCredit called', { cost, userId });
13
+ if (!userId) return false;
14
+
15
+ setIsDeducting(true);
25
16
  try {
26
- const res = await mutateAsyncRef.current(cost);
27
- if (__DEV__) console.log('[useDeductCredit] mutation result:', JSON.stringify(res));
17
+ const res = await repository.deductCredit(userId, cost);
18
+ if (__DEV__) console.log('[useDeductCredit] deduction result:', JSON.stringify(res));
19
+
28
20
  if (!res.success) {
29
21
  if (__DEV__) console.log('[useDeductCredit] deduction FAILED:', res.error?.code, res.error?.message);
30
- // Call onCreditsExhausted for any credit-related error codes
22
+
31
23
  if (res.error?.code === "CREDITS_EXHAUSTED" || res.error?.code === "DEDUCT_ERR" || res.error?.code === "NO_CREDITS") {
32
24
  if (__DEV__) console.log('[useDeductCredit] Credits exhausted, calling onCreditsExhausted callback');
33
25
  onCreditsExhausted?.();
34
26
  }
35
27
  return false;
36
28
  }
29
+
37
30
  if (__DEV__) console.log('[useDeductCredit] deduction SUCCESS, remaining:', res.remainingCredits);
38
31
  return true;
39
32
  } catch (error) {
@@ -43,8 +36,10 @@ export const useDeductCredit = ({
43
36
  error: error instanceof Error ? error.message : String(error)
44
37
  });
45
38
  return false;
39
+ } finally {
40
+ setIsDeducting(false);
46
41
  }
47
- }, [onCreditsExhausted, userId]);
42
+ }, [userId, repository, onCreditsExhausted]);
48
43
 
49
44
  const checkCredits = useCallback(async (cost: number = 1): Promise<boolean> => {
50
45
  if (!userId) return false;
@@ -55,11 +50,7 @@ export const useDeductCredit = ({
55
50
  if (!userId) return false;
56
51
  try {
57
52
  const result = await repository.refundCredit(userId, amount);
58
- if (result.success) {
59
- // Real-time sync (onSnapshot) handles automatic update
60
- return true;
61
- }
62
- return false;
53
+ return result.success;
63
54
  } catch (error) {
64
55
  if (__DEV__) {
65
56
  console.error('[useDeductCredit] Unexpected error during credit refund', {
@@ -76,6 +67,6 @@ export const useDeductCredit = ({
76
67
  checkCredits,
77
68
  deductCredit,
78
69
  refundCredits,
79
- isDeducting: mutation.isPending
70
+ isDeducting
80
71
  };
81
72
  };
@@ -1,7 +1,9 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { NavigationProp } from "@react-navigation/native";
3
3
  import type { ImageSourcePropType } from "react-native";
4
- import { usePremium } from "../../subscription/presentation/usePremium";
4
+ import { usePremiumStatus } from "../../subscription/presentation/usePremiumStatus";
5
+ import { usePremiumPackages } from "../../subscription/presentation/usePremiumPackages";
6
+ import { usePremiumActions } from "../../subscription/presentation/usePremiumActions";
5
7
  import { useSubscriptionFlowStore } from "../../subscription/presentation/useSubscriptionFlow";
6
8
  import { usePaywallVisibility } from "../../subscription/presentation/usePaywallVisibility";
7
9
  import { PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../entities/types";
@@ -18,11 +20,6 @@ export interface PaywallOrchestratorOptions {
18
20
  creditsLabel?: string;
19
21
  }
20
22
 
21
- /**
22
- * High-level orchestrator for Paywall navigation.
23
- * Handles automatic triggers (post-onboarding) and manual triggers (showPaywall state).
24
- * Centralizes handlers for success, close, and feedback triggers.
25
- */
26
23
  export function usePaywallOrchestrator({
27
24
  navigation,
28
25
  translations,
@@ -34,17 +31,10 @@ export function usePaywallOrchestrator({
34
31
  bestValueIdentifier = "yearly",
35
32
  creditsLabel,
36
33
  }: PaywallOrchestratorOptions) {
37
- // Get all premium data and actions from usePremium
38
- const {
39
- isPremium,
40
- packages,
41
- credits,
42
- isSyncing,
43
- purchasePackage,
44
- restorePurchase,
45
- } = usePremium();
34
+ const { isPremium, isSyncing, credits } = usePremiumStatus();
35
+ const { packages } = usePremiumPackages();
36
+ const { purchasePackage, restorePurchase } = usePremiumActions();
46
37
 
47
- // Selectors for stable references and fine-grained updates
48
38
  const isOnboardingComplete = useSubscriptionFlowStore((state) => state.isOnboardingComplete);
49
39
  const showPostOnboardingPaywall = useSubscriptionFlowStore((state) => state.showPostOnboardingPaywall);
50
40
  const paywallShown = useSubscriptionFlowStore((state) => state.paywallShown);
@@ -74,7 +64,6 @@ export function usePaywallOrchestrator({
74
64
  const shouldShowManual = showPaywall && !isPremium && !isAuthModalOpen;
75
65
 
76
66
  if (shouldShowPostOnboarding || shouldShowManual) {
77
- // Guard against double navigation in same render cycle
78
67
  if (hasNavigatedRef.current) return;
79
68
  hasNavigatedRef.current = true;
80
69
 
@@ -84,7 +73,6 @@ export function usePaywallOrchestrator({
84
73
  });
85
74
 
86
75
  navigation.navigate("PaywallScreen", {
87
- // UI Props
88
76
  translations,
89
77
  legalUrls,
90
78
  features,
@@ -92,14 +80,10 @@ export function usePaywallOrchestrator({
92
80
  creditsLabel,
93
81
  heroImage,
94
82
  source: shouldShowPostOnboarding ? "onboarding" : "manual",
95
-
96
- // Data Props
97
83
  packages,
98
84
  isPremium,
99
85
  credits,
100
86
  isSyncing,
101
-
102
- // Action Props
103
87
  onPurchase: purchasePackage,
104
88
  onRestore: restorePurchase,
105
89
  onClose: handleClose,
@@ -108,12 +92,11 @@ export function usePaywallOrchestrator({
108
92
  if (shouldShowPostOnboarding) {
109
93
  markPaywallShown();
110
94
  }
111
-
95
+
112
96
  if (showPaywall) {
113
97
  closePaywall();
114
98
  }
115
99
  } else {
116
- // Reset navigation flag if conditions no longer met
117
100
  hasNavigatedRef.current = false;
118
101
  }
119
102
  }, [
@@ -1,4 +1,4 @@
1
- import { useMutation, useQueryClient } from "@umituz/react-native-design-system/tanstack";
1
+ import { useMutation } from "@umituz/react-native-design-system/tanstack";
2
2
  import type { PurchasesPackage } from "react-native-purchases";
3
3
  import { useAlert } from "@umituz/react-native-design-system/molecules";
4
4
  import {
@@ -6,7 +6,6 @@ import {
6
6
  selectUserId,
7
7
  } from "@umituz/react-native-auth";
8
8
  import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
9
- import { SUBSCRIPTION_QUERY_KEYS } from "./subscriptionQueryKeys";
10
9
  import { getErrorMessage } from "../../../revenuecat/core/errors/RevenueCatErrorHandler";
11
10
 
12
11
  interface PurchaseMutationResult {
@@ -16,7 +15,6 @@ interface PurchaseMutationResult {
16
15
 
17
16
  export const usePurchasePackage = () => {
18
17
  const userId = useAuthStore(selectUserId);
19
- const queryClient = useQueryClient();
20
18
  const { showSuccess, showError } = useAlert();
21
19
 
22
20
  return useMutation({
@@ -37,14 +35,6 @@ export const usePurchasePackage = () => {
37
35
  onSuccess: (result) => {
38
36
  if (result.success) {
39
37
  showSuccess("Purchase Successful", "Your subscription is now active!");
40
-
41
- // Invalidate packages cache (no event listener for packages)
42
- queryClient.invalidateQueries({ queryKey: SUBSCRIPTION_QUERY_KEYS.packages });
43
-
44
- // Credits and subscription status updated via real-time sync:
45
- // - Credits: Firestore onSnapshot (useCreditsRealTime)
46
- // - Subscription status: PREMIUM_STATUS_CHANGED event (RevenueCat)
47
- // No manual invalidation needed here
48
38
  } else {
49
39
  showError("Purchase Failed", "Unable to complete purchase. Please try again.");
50
40
  }
@@ -1,11 +1,10 @@
1
- import { useMutation, useQueryClient } from "@umituz/react-native-design-system/tanstack";
1
+ import { useMutation } from "@umituz/react-native-design-system/tanstack";
2
2
  import { useAlert } from "@umituz/react-native-design-system/molecules";
3
3
  import {
4
4
  useAuthStore,
5
5
  selectUserId,
6
6
  } from "@umituz/react-native-auth";
7
7
  import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
8
- import { SUBSCRIPTION_QUERY_KEYS } from "./subscriptionQueryKeys";
9
8
  import { getErrorMessage } from "../../../revenuecat/core/errors/RevenueCatErrorHandler";
10
9
 
11
10
  interface RestoreResult {
@@ -15,7 +14,6 @@ interface RestoreResult {
15
14
 
16
15
  export const useRestorePurchase = () => {
17
16
  const userId = useAuthStore(selectUserId);
18
- const queryClient = useQueryClient();
19
17
  const { showSuccess, showInfo, showError } = useAlert();
20
18
 
21
19
  return useMutation({
@@ -29,14 +27,6 @@ export const useRestorePurchase = () => {
29
27
  },
30
28
  onSuccess: (result) => {
31
29
  if (result.success) {
32
- // Invalidate packages cache (no event listener for packages)
33
- queryClient.invalidateQueries({ queryKey: SUBSCRIPTION_QUERY_KEYS.packages });
34
-
35
- // Credits and subscription status updated via real-time sync:
36
- // - Credits: Firestore onSnapshot (useCreditsRealTime)
37
- // - Subscription status: PREMIUM_STATUS_CHANGED event (RevenueCat)
38
- // No manual invalidation needed here
39
-
40
30
  if (result.productId) {
41
31
  showSuccess("Restore Successful", "Your subscription has been restored!");
42
32
  } else {
@@ -1,21 +1,20 @@
1
- import { useQuery, useQueryClient } from "@umituz/react-native-design-system/tanstack";
2
- import { useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { useState, useEffect, useSyncExternalStore, useRef, useCallback } from "react";
3
2
  import {
4
3
  useAuthStore,
5
4
  selectUserId,
6
5
  } from "@umituz/react-native-auth";
7
6
  import { SubscriptionManager } from '../../infrastructure/managers/SubscriptionManager';
8
7
  import { initializationState } from "../../infrastructure/state/initializationState";
9
- import {
10
- SUBSCRIPTION_QUERY_KEYS,
11
- } from "./subscriptionQueryKeys";
12
8
 
13
9
  export const useSubscriptionPackages = () => {
14
10
  const userId = useAuthStore(selectUserId);
15
11
  const isConfigured = SubscriptionManager.isConfigured();
16
- const queryClient = useQueryClient();
17
12
  const prevUserIdRef = useRef(userId);
18
13
 
14
+ const [packages, setPackages] = useState<any[] | null>(null);
15
+ const [isLoading, setIsLoading] = useState(true);
16
+ const [error, setError] = useState<Error | null>(null);
17
+
19
18
  const initState = useSyncExternalStore(
20
19
  initializationState.subscribe,
21
20
  initializationState.getSnapshot,
@@ -24,45 +23,48 @@ export const useSubscriptionPackages = () => {
24
23
 
25
24
  const isInitialized = initState.initialized || SubscriptionManager.isInitialized();
26
25
 
27
- const query = useQuery({
28
- queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, userId ?? "anonymous"] as const,
29
- queryFn: async () => {
30
- return SubscriptionManager.getPackages();
31
- },
32
- enabled: isConfigured && isInitialized,
33
- gcTime: 5 * 60 * 1000,
34
- staleTime: 2 * 60 * 1000,
35
- refetchOnMount: true,
36
- refetchOnWindowFocus: false,
37
- refetchOnReconnect: true,
38
- });
26
+ const fetchPackages = useCallback(async () => {
27
+ if (!isConfigured || !isInitialized) {
28
+ setPackages(null);
29
+ setIsLoading(false);
30
+ setError(null);
31
+ return;
32
+ }
33
+
34
+ setIsLoading(true);
35
+ setError(null);
36
+
37
+ try {
38
+ const result = await SubscriptionManager.getPackages();
39
+ setPackages(result);
40
+ } catch (err) {
41
+ setError(err as Error);
42
+ } finally {
43
+ setIsLoading(false);
44
+ }
45
+ }, [isConfigured, isInitialized]);
46
+
47
+ useEffect(() => {
48
+ fetchPackages();
49
+ }, [fetchPackages]);
39
50
 
40
51
  useEffect(() => {
41
52
  const prevUserId = prevUserIdRef.current;
42
53
  prevUserIdRef.current = userId;
43
54
 
44
55
  if (prevUserId !== userId) {
45
- // Clean up previous user's cache to prevent data leakage
46
- if (prevUserId) {
47
- queryClient.cancelQueries({
48
- queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, prevUserId],
49
- });
50
- queryClient.removeQueries({
51
- queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, prevUserId],
52
- });
53
- } else {
54
- queryClient.cancelQueries({
55
- queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, "anonymous"],
56
- });
57
- queryClient.removeQueries({
58
- queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, "anonymous"],
59
- });
60
- }
61
-
62
- // No need to invalidate - removeQueries already cleared cache
63
- // Query will refetch automatically on mount if needed
56
+ fetchPackages();
64
57
  }
65
- }, [userId, queryClient]);
58
+ }, [userId, fetchPackages]);
59
+
60
+ const refetch = useCallback(() => {
61
+ fetchPackages();
62
+ }, [fetchPackages]);
66
63
 
67
- return query;
64
+ return {
65
+ data: packages,
66
+ isLoading,
67
+ error,
68
+ refetch,
69
+ };
68
70
  };
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { PurchasesPackage } from "react-native-purchases";
3
- import { usePremium } from "./usePremium";
3
+ import { usePremiumActions } from "./usePremiumActions";
4
4
  import type { PurchaseSource } from "../core/SubscriptionConstants";
5
5
  import { authPurchaseStateManager } from "../infrastructure/utils/authPurchaseState";
6
6
  import { requireAuthentication } from "./utils/authCheckUtils";
@@ -27,14 +27,10 @@ interface UseAuthAwarePurchaseResult {
27
27
  executeSavedPurchase: () => Promise<boolean>;
28
28
  }
29
29
 
30
- /**
31
- * Hook for purchase operations that handle authentication.
32
- * Automatically saves pending purchases and shows auth modal when needed.
33
- */
34
30
  export const useAuthAwarePurchase = (
35
31
  params?: UseAuthAwarePurchaseParams
36
32
  ): UseAuthAwarePurchaseResult => {
37
- const { purchasePackage, restorePurchase } = usePremium();
33
+ const { purchasePackage, restorePurchase } = usePremiumActions();
38
34
  const isExecutingSavedRef = useRef(false);
39
35
 
40
36
  const executeSavedPurchase = useCallback(async (): Promise<boolean> => {
@@ -55,7 +51,6 @@ export const useAuthAwarePurchase = (
55
51
  }
56
52
  }, [purchasePackage]);
57
53
 
58
- // Auto-execute saved purchase when user authenticates
59
54
  useEffect(() => {
60
55
  const authProvider = authPurchaseStateManager.getProvider();
61
56
  const hasUser = authProvider && authProvider.hasFirebaseUser();
@@ -74,12 +69,10 @@ export const useAuthAwarePurchase = (
74
69
  const authProvider = authPurchaseStateManager.getProvider();
75
70
 
76
71
  if (!requireAuthentication(authProvider)) {
77
- // User not authenticated, purchase saved and auth modal shown
78
72
  authPurchaseStateManager.savePurchase(pkg, source || params?.source || "settings");
79
73
  return false;
80
74
  }
81
75
 
82
- // User authenticated, proceed with purchase
83
76
  const result = await purchasePackage(pkg);
84
77
  return result;
85
78
  },
@@ -1,26 +1,21 @@
1
- import { useQuery, useQueryClient } from "@umituz/react-native-design-system/tanstack";
2
- import { useEffect, useSyncExternalStore } from "react";
1
+ import { useState, useEffect, useSyncExternalStore, useCallback } from "react";
3
2
  import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
4
3
  import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
5
4
  import { initializationState } from "../infrastructure/state/initializationState";
6
5
  import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
7
- import { SubscriptionStatusResult } from "./useSubscriptionStatus.types";
6
+ import type { SubscriptionStatusResult } from "./useSubscriptionStatus.types";
7
+ import type { PremiumStatus } from "../core/types/PremiumStatus";
8
8
  import { isAuthenticated } from "../utils/authGuards";
9
- import { SHORT_CACHE_CONFIG } from "../../../shared/infrastructure/react-query/queryConfig";
10
- import { usePreviousUserCleanup } from "../../../shared/infrastructure/react-query/hooks/usePreviousUserCleanup";
11
-
12
- export const subscriptionStatusQueryKeys = {
13
- all: ["subscriptionStatus"] as const,
14
- user: (userId: string | null | undefined) =>
15
- userId ? (["subscriptionStatus", userId] as const) : (["subscriptionStatus"] as const),
16
- };
17
9
 
18
10
  export const useSubscriptionStatus = (): SubscriptionStatusResult => {
19
11
  const userId = useAuthStore(selectUserId);
20
- const queryClient = useQueryClient();
21
12
  const isConfigured = SubscriptionManager.isConfigured();
22
13
  const hasUser = isAuthenticated(userId);
23
14
 
15
+ const [data, setData] = useState<PremiumStatus | null>(null);
16
+ const [isLoading, setIsLoading] = useState(true);
17
+ const [error, setError] = useState<Error | null>(null);
18
+
24
19
  const initState = useSyncExternalStore(
25
20
  initializationState.subscribe,
26
21
  initializationState.getSnapshot,
@@ -31,22 +26,30 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
31
26
  ? initState.initialized && initState.userId === userId
32
27
  : false;
33
28
 
34
- const queryEnabled = hasUser && isConfigured && isInitialized;
29
+ const fetchStatus = useCallback(async () => {
30
+ if (!hasUser || !isConfigured || !isInitialized) {
31
+ setData(null);
32
+ setIsLoading(false);
33
+ setError(null);
34
+ return;
35
+ }
35
36
 
36
- const { data, status, error, refetch } = useQuery({
37
- queryKey: subscriptionStatusQueryKeys.user(userId),
38
- queryFn: async () => {
39
- if (!hasUser) {
40
- return null;
41
- }
37
+ setIsLoading(true);
38
+ setError(null);
42
39
 
43
- return SubscriptionManager.checkPremiumStatus();
44
- },
45
- enabled: queryEnabled,
46
- ...SHORT_CACHE_CONFIG,
47
- });
40
+ try {
41
+ const result = await SubscriptionManager.checkPremiumStatus();
42
+ setData(result);
43
+ } catch (err) {
44
+ setError(err as Error);
45
+ } finally {
46
+ setIsLoading(false);
47
+ }
48
+ }, [hasUser, isConfigured, isInitialized]);
48
49
 
49
- usePreviousUserCleanup(userId, queryClient, subscriptionStatusQueryKeys.user);
50
+ useEffect(() => {
51
+ fetchStatus();
52
+ }, [fetchStatus]);
50
53
 
51
54
  useEffect(() => {
52
55
  if (!hasUser) return undefined;
@@ -55,34 +58,18 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
55
58
  SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED,
56
59
  (event: { userId: string; isPremium: boolean }) => {
57
60
  if (event.userId === userId) {
58
- queryClient.invalidateQueries({
59
- queryKey: subscriptionStatusQueryKeys.user(userId),
60
- });
61
+ fetchStatus();
61
62
  }
62
63
  }
63
64
  );
64
65
 
65
66
  return unsubscribe;
66
- }, [userId, hasUser, queryClient]);
67
-
68
- const isLoading = status === "pending";
67
+ }, [userId, hasUser, fetchStatus]);
69
68
 
70
69
  return {
71
- isPremium: data?.isPremium ?? false,
72
- expirationDate: data?.expirationDate ?? null,
73
- willRenew: data?.willRenew ?? false,
74
- productIdentifier: data?.productIdentifier ?? null,
75
- originalPurchaseDate: data?.originalPurchaseDate ?? null,
76
- latestPurchaseDate: data?.latestPurchaseDate ?? null,
77
- billingIssuesDetected: data?.billingIssuesDetected ?? false,
78
- isSandbox: data?.isSandbox ?? false,
79
- periodType: data?.periodType ?? null,
80
- packageType: data?.packageType ?? null,
81
- store: data?.store ?? null,
82
- gracePeriodExpiresDate: data?.gracePeriodExpiresDate ?? null,
83
- unsubscribeDetectedAt: data?.unsubscribeDetectedAt ?? null,
70
+ ...data,
84
71
  isLoading,
85
- error: error as Error | null,
86
- refetch,
87
- };
72
+ error,
73
+ refetch: fetchStatus,
74
+ } as SubscriptionStatusResult;
88
75
  };
@@ -1,17 +1,11 @@
1
- import { useQuery } from "@umituz/react-native-design-system/tanstack";
2
- import { useMemo } from "react";
1
+ import { useState, useEffect } from "react";
3
2
  import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
4
- import { MEDIUM_CACHE_CONFIG } from "../../../../shared/infrastructure/react-query/queryConfig";
3
+ import { collection, onSnapshot, query, orderBy, limit, Query } from "firebase/firestore";
5
4
  import type {
6
5
  CreditLog,
7
6
  TransactionRepositoryConfig,
8
7
  } from "../../domain/types/transaction.types";
9
- import { TransactionRepository } from "../../infrastructure/repositories/transaction/TransactionRepository";
10
-
11
- const transactionQueryKeys = {
12
- all: ["transactions"] as const,
13
- user: (userId: string) => ["transactions", userId] as const,
14
- };
8
+ import { requireFirestore } from "../../../../shared/infrastructure/firestore/collectionUtils";
15
9
 
16
10
  export interface UseTransactionHistoryParams {
17
11
  config: TransactionRepositoryConfig;
@@ -28,43 +22,75 @@ interface UseTransactionHistoryResult {
28
22
 
29
23
  export function useTransactionHistory({
30
24
  config,
31
- limit = 50,
25
+ limit: limitCount = 50,
32
26
  }: UseTransactionHistoryParams): UseTransactionHistoryResult {
33
27
  const userId = useAuthStore(selectUserId);
28
+ const [transactions, setTransactions] = useState<CreditLog[]>([]);
29
+ const [isLoading, setIsLoading] = useState(true);
30
+ const [error, setError] = useState<Error | null>(null);
34
31
 
35
- const repository = useMemo(
36
- () => new TransactionRepository(config),
37
- [config]
38
- );
32
+ useEffect(() => {
33
+ if (!userId) {
34
+ setTransactions([]);
35
+ setIsLoading(false);
36
+ setError(null);
37
+ return;
38
+ }
39
39
 
40
- const hasUser = !!userId;
40
+ setIsLoading(true);
41
+ setError(null);
41
42
 
42
- const { data, isLoading, error, refetch } = useQuery({
43
- queryKey: [...transactionQueryKeys.user(userId ?? ""), limit],
44
- queryFn: async () => {
45
- if (!userId) return [];
43
+ try {
44
+ const db = requireFirestore();
45
+ const collectionPath = config.useUserSubcollection
46
+ ? `users/${userId}/${config.collectionName}`
47
+ : config.collectionName;
46
48
 
47
- const result = await repository.getTransactions({
48
- userId,
49
- limit,
50
- });
49
+ const q = query(
50
+ collection(db, collectionPath),
51
+ orderBy("timestamp", "desc"),
52
+ limit(limitCount)
53
+ ) as Query;
51
54
 
52
- if (!result.success) {
53
- throw new Error(result.error?.message || "Failed to fetch history");
54
- }
55
+ const unsubscribe = onSnapshot(
56
+ q,
57
+ (snapshot) => {
58
+ const logs: CreditLog[] = [];
59
+ snapshot.forEach((doc) => {
60
+ logs.push({
61
+ id: doc.id,
62
+ ...doc.data(),
63
+ } as CreditLog);
64
+ });
65
+ setTransactions(logs);
66
+ setIsLoading(false);
67
+ },
68
+ (err) => {
69
+ console.error("[useTransactionHistory] Snapshot error:", err);
70
+ setError(err as Error);
71
+ setIsLoading(false);
72
+ }
73
+ );
55
74
 
56
- return result.data ?? [];
57
- },
58
- enabled: hasUser,
59
- ...MEDIUM_CACHE_CONFIG,
60
- });
75
+ return () => unsubscribe();
76
+ } catch (err) {
77
+ const error = err instanceof Error ? err : new Error(String(err));
78
+ console.error("[useTransactionHistory] Setup error:", err);
79
+ setError(error);
80
+ setIsLoading(false);
81
+ }
82
+ }, [userId, config.collectionName, config.useUserSubcollection, limitCount]);
61
83
 
62
- const transactions = data ?? [];
84
+ const refetch = () => {
85
+ if (__DEV__) {
86
+ console.warn("[useTransactionHistory] Refetch called - not needed for real-time sync");
87
+ }
88
+ };
63
89
 
64
90
  return {
65
91
  transactions,
66
92
  isLoading,
67
- error: error as Error | null,
93
+ error,
68
94
  refetch,
69
95
  isEmpty: transactions.length === 0,
70
96
  };
package/src/index.ts CHANGED
@@ -10,12 +10,6 @@ export {
10
10
  ANONYMOUS_CACHE_KEY,
11
11
  } from "./domains/subscription/core/SubscriptionConstants";
12
12
 
13
- // Domain Events
14
- export { SUBSCRIPTION_EVENTS } from "./domains/subscription/core/events/SubscriptionEvents";
15
- export type { SubscriptionEventType, SyncStatusChangedEvent } from "./domains/subscription/core/events/SubscriptionEvents";
16
- export type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "./domains/subscription/core/SubscriptionEvents";
17
- export { FLOW_EVENTS } from "./domains/subscription/core/events/FlowEvents";
18
- export type { FlowEventType, OnboardingCompletedEvent, PaywallShownEvent, PaywallClosedEvent } from "./domains/subscription/core/events/FlowEvents";
19
13
  export type {
20
14
  UserTierType,
21
15
  SubscriptionStatusType,
@@ -25,9 +19,7 @@ export type {
25
19
  PurchaseSource,
26
20
  PurchaseType,
27
21
  } from "./domains/subscription/core/SubscriptionConstants";
28
- export type { SubscriptionMetadata } from "./domains/subscription/core/types/SubscriptionMetadata";
29
- export type { PremiumStatus as PremiumStatusMetadata } from "./domains/subscription/core/types/PremiumStatus";
30
- export type { CreditInfo } from "./domains/subscription/core/types/CreditInfo";
22
+
31
23
  export {
32
24
  createDefaultSubscriptionStatus,
33
25
  isSubscriptionValid,
@@ -35,6 +27,17 @@ export {
35
27
  } from "./domains/subscription/core/SubscriptionStatus";
36
28
  export type { SubscriptionStatus, StatusResolverInput } from "./domains/subscription/core/SubscriptionStatus";
37
29
 
30
+ // Domain Events
31
+ export { SUBSCRIPTION_EVENTS } from "./domains/subscription/core/events/SubscriptionEvents";
32
+ export type { SubscriptionEventType, SyncStatusChangedEvent } from "./domains/subscription/core/events/SubscriptionEvents";
33
+ export type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "./domains/subscription/core/SubscriptionEvents";
34
+ export { FLOW_EVENTS } from "./domains/subscription/core/events/FlowEvents";
35
+ export type { FlowEventType, OnboardingCompletedEvent, PaywallShownEvent, PaywallClosedEvent } from "./domains/subscription/core/events/FlowEvents";
36
+
37
+ export type { SubscriptionMetadata } from "./domains/subscription/core/types/SubscriptionMetadata";
38
+ export type { PremiumStatus as PremiumStatusMetadata } from "./domains/subscription/core/types/PremiumStatus";
39
+ export type { CreditInfo } from "./domains/subscription/core/types/CreditInfo";
40
+
38
41
  // Application Layer - Ports
39
42
  export type { ISubscriptionRepository } from "./shared/application/ports/ISubscriptionRepository";
40
43
  export type { IRevenueCatService } from "./shared/application/ports/IRevenueCatService";
@@ -51,29 +54,29 @@ export {
51
54
  } from "./shared/utils/Result";
52
55
  export type { Result, Success, Failure } from "./shared/utils/Result";
53
56
 
54
- // Infrastructure Layer (Services & Repositories)
57
+ // Infrastructure Layer
55
58
  export { initializeSubscription } from "./domains/subscription/application/initializer/SubscriptionInitializer";
56
59
  export type { SubscriptionInitConfig, CreditPackageConfig } from "./domains/subscription/application/SubscriptionInitializerTypes";
57
60
 
58
61
  export { CreditsRepository } from "./domains/credits/infrastructure/CreditsRepository";
59
- export {
60
- configureCreditsRepository,
61
- getCreditsRepository,
62
- getCreditsConfig,
63
- isCreditsRepositoryConfigured
62
+ export {
63
+ configureCreditsRepository,
64
+ getCreditsRepository,
65
+ getCreditsConfig,
66
+ isCreditsRepositoryConfigured
64
67
  } from "./domains/credits/infrastructure/CreditsRepositoryManager";
65
68
 
69
+ export { CreditLimitService, calculateCreditLimit } from "./domains/credits/domain/services/CreditLimitService";
70
+
66
71
  // Presentation Layer - Hooks
67
72
  export { useAuthAwarePurchase } from "./domains/subscription/presentation/useAuthAwarePurchase";
68
73
  export { useCredits } from "./domains/credits/presentation/useCredits";
69
74
  export { useDeductCredit } from "./domains/credits/presentation/deduct-credit/useDeductCredit";
70
75
  export { useFeatureGate } from "./domains/subscription/presentation/useFeatureGate";
71
76
  export { usePaywallVisibility, paywallControl } from "./domains/subscription/presentation/usePaywallVisibility";
72
- export { usePremium } from "./domains/subscription/presentation/usePremium";
73
77
  export { usePremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
74
78
  export { usePremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
75
79
  export { usePremiumActions } from "./domains/subscription/presentation/usePremiumActions";
76
- export type { UsePremiumResult } from "./domains/subscription/presentation/usePremium.types";
77
80
  export type { PremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
78
81
  export type { PremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
79
82
  export type { PremiumActions } from "./domains/subscription/presentation/usePremiumActions";
@@ -81,7 +84,6 @@ export { useSubscriptionFlowStore } from "./domains/subscription/presentation/us
81
84
  export type { SubscriptionFlowState, SubscriptionFlowActions, SubscriptionFlowStore } from "./domains/subscription/presentation/useSubscriptionFlow";
82
85
  export { useSubscriptionStatus } from "./domains/subscription/presentation/useSubscriptionStatus";
83
86
  export type { SubscriptionStatusResult } from "./domains/subscription/presentation/useSubscriptionStatus.types";
84
- export { useSyncStatusListener } from "./domains/subscription/presentation/useSyncStatusListener";
85
87
  export { usePaywallFeedback } from "./presentation/hooks/feedback/usePaywallFeedback";
86
88
  export {
87
89
  usePaywallFeedbackSubmit,
@@ -123,7 +125,6 @@ export type {
123
125
  CreditsResult,
124
126
  DeductCreditsResult,
125
127
  } from "./domains/credits/core/Credits";
126
- export { CreditLimitService, calculateCreditLimit } from "./domains/credits/domain/services/CreditLimitService";
127
128
 
128
129
  // Utils
129
130
  export {
@@ -170,9 +171,6 @@ export {
170
171
  export { getAppVersion, validatePlatform } from "./utils/appUtils";
171
172
  export { toDate, toISOString, toTimestamp, getCurrentISOString } from "./shared/utils/dateConverter";
172
173
 
173
- // Credits Query Keys
174
- export { creditsQueryKeys } from "./domains/credits/presentation/creditsQueryKeys";
175
-
176
174
  // Paywall Types & Utils
177
175
  export type { PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "./domains/paywall/entities/types";
178
176
  export { createPaywallTranslations, createFeedbackTranslations } from "./domains/paywall/utils/paywallTranslationUtils";
@@ -1,5 +0,0 @@
1
- export const creditsQueryKeys = {
2
- all: ["credits"] as const,
3
- user: (userId: string | null | undefined) =>
4
- userId ? (["credits", userId] as const) : (["credits"] as const),
5
- };
@@ -1,78 +0,0 @@
1
- import type { QueryClient } from "@umituz/react-native-design-system/tanstack";
2
- import { timezoneService } from "@umituz/react-native-design-system/timezone";
3
- import type { UserCredits, DeductCreditsResult } from "../../core/Credits";
4
- import type { CreditsRepository } from "../../infrastructure/CreditsRepository";
5
- import { creditsQueryKeys } from "../creditsQueryKeys";
6
- import { calculateRemaining } from "../../../../shared/utils/numberUtils.core";
7
-
8
- export interface MutationContext {
9
- previousCredits: UserCredits | null;
10
- skippedOptimistic: boolean;
11
- wasInsufficient?: boolean;
12
- capturedUserId: string | null;
13
- }
14
-
15
- export function createDeductCreditMutationConfig(
16
- userId: string | undefined,
17
- repository: CreditsRepository,
18
- queryClient: QueryClient
19
- ) {
20
- return {
21
- mutationFn: async (cost: number): Promise<DeductCreditsResult> => {
22
- if (__DEV__) console.log('[deductCreditMutation] mutationFn called', { userId, cost });
23
- if (!userId) throw new Error("User not authenticated");
24
- return repository.deductCredit(userId, cost);
25
- },
26
- onMutate: async (cost: number): Promise<MutationContext> => {
27
- if (!userId) {
28
- return { previousCredits: null, skippedOptimistic: true, capturedUserId: null };
29
- }
30
-
31
- const capturedUserId = userId;
32
-
33
- await queryClient.cancelQueries({ queryKey: creditsQueryKeys.user(capturedUserId) });
34
- const previousCredits = queryClient.getQueryData<UserCredits>(
35
- creditsQueryKeys.user(capturedUserId)
36
- );
37
-
38
- if (!previousCredits) {
39
- return {
40
- previousCredits: null as UserCredits | null,
41
- skippedOptimistic: true,
42
- capturedUserId
43
- };
44
- }
45
-
46
- const newCredits = calculateRemaining(previousCredits.credits, cost);
47
-
48
- queryClient.setQueryData<UserCredits | null>(
49
- creditsQueryKeys.user(capturedUserId),
50
- (old) => {
51
- if (!old) return old;
52
- return {
53
- ...old,
54
- credits: newCredits,
55
- lastUpdatedAt: timezoneService.getNow()
56
- };
57
- }
58
- );
59
-
60
- return {
61
- previousCredits,
62
- skippedOptimistic: false,
63
- wasInsufficient: previousCredits.credits < cost,
64
- capturedUserId
65
- };
66
- },
67
- onError: (_err: unknown, _cost: number, context: MutationContext | undefined) => {
68
- if (context?.capturedUserId && context.previousCredits && !context.skippedOptimistic) {
69
- queryClient.setQueryData(
70
- creditsQueryKeys.user(context.capturedUserId),
71
- context.previousCredits
72
- );
73
- }
74
- },
75
- // onSuccess removed - real-time sync (onSnapshot) handles automatic updates
76
- // Optimistic update already applied, real-time listener will confirm actual value
77
- };
78
- }
@@ -1,6 +0,0 @@
1
- export const SUBSCRIPTION_QUERY_KEYS = {
2
- packages: ["subscription", "packages"] as const,
3
- initialized: (userId: string) =>
4
- ["subscription", "initialized", userId] as const,
5
- customerInfo: ["subscription", "customerInfo"] as const,
6
- } as const;
@@ -1,33 +0,0 @@
1
- import { useMemo } from 'react';
2
- import { usePremiumStatus } from './usePremiumStatus';
3
- import { usePremiumPackages } from './usePremiumPackages';
4
- import { usePremiumActions } from './usePremiumActions';
5
- import { UsePremiumResult } from './usePremium.types';
6
-
7
- /**
8
- * Facade hook that combines status, packages, and actions.
9
- *
10
- * Consider using the focused hooks for better performance:
11
- * - usePremiumStatus() - when you only need premium status
12
- * - usePremiumPackages() - when you only need package data
13
- * - usePremiumActions() - when you only need actions
14
- *
15
- * This facade re-renders when ANY of the sub-hooks change, whereas focused hooks
16
- * only re-render when their specific data changes.
17
- */
18
- export const usePremium = (): UsePremiumResult => {
19
- const status = usePremiumStatus();
20
- const packages = usePremiumPackages();
21
- const actions = usePremiumActions();
22
-
23
- return useMemo(() => ({
24
- ...status,
25
- ...packages,
26
- ...actions,
27
- isLoading: status.isSyncing || packages.isLoading || actions.isLoading,
28
- }), [
29
- status,
30
- packages,
31
- actions,
32
- ]);
33
- };
@@ -1,16 +0,0 @@
1
- import type { PurchasesPackage } from 'react-native-purchases';
2
- import type { UserCredits } from '../../credits/core/Credits';
3
-
4
- export interface UsePremiumResult {
5
- isPremium: boolean;
6
- isLoading: boolean;
7
- packages: PurchasesPackage[];
8
- credits: UserCredits | null;
9
- showPaywall: boolean;
10
- isSyncing: boolean;
11
- purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
12
- restorePurchase: () => Promise<boolean>;
13
- setShowPaywall: (show: boolean) => void;
14
- closePaywall: () => void;
15
- openPaywall: () => void;
16
- }
@@ -1,22 +0,0 @@
1
- import { useEffect, useRef } from "react";
2
- import type { QueryClient } from "@umituz/react-native-design-system/tanstack";
3
- import { isAuthenticated } from "../../../../domains/subscription/utils/authGuards";
4
-
5
- export function usePreviousUserCleanup(
6
- userId: string | null | undefined,
7
- queryClient: QueryClient,
8
- queryKey: (userId: string) => readonly unknown[]
9
- ): void {
10
- const prevUserIdRef = useRef(userId);
11
-
12
- useEffect(() => {
13
- const prevUserId = prevUserIdRef.current;
14
- prevUserIdRef.current = userId;
15
-
16
- if (prevUserId !== userId && isAuthenticated(prevUserId)) {
17
- queryClient.removeQueries({
18
- queryKey: queryKey(prevUserId),
19
- });
20
- }
21
- }, [userId, queryClient, queryKey]);
22
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * Query cache configurations for optimal performance
3
- * Uses event-based invalidation via subscriptionEventBus for real-time updates
4
- */
5
-
6
- /**
7
- * Short-lived cache for frequently changing data (credits, subscription status)
8
- * Events automatically invalidate the cache, so we can safely cache for 60s
9
- */
10
- export const SHORT_CACHE_CONFIG = {
11
- gcTime: 1000 * 60, // 1 minute - keep in memory for 1 minute
12
- staleTime: 1000 * 30, // 30 seconds - consider stale after 30s
13
- refetchOnMount: false, // Don't refetch on mount if cache exists
14
- refetchOnWindowFocus: false, // Don't refetch on app focus
15
- refetchOnReconnect: true, // Refetch on reconnect only
16
- };
17
-
18
- /**
19
- * Medium cache for relatively stable data (packages, transaction history)
20
- */
21
- export const MEDIUM_CACHE_CONFIG = {
22
- gcTime: 1000 * 60 * 5, // 5 minutes
23
- staleTime: 1000 * 60 * 2, // 2 minutes
24
- refetchOnMount: false,
25
- refetchOnWindowFocus: false,
26
- refetchOnReconnect: true,
27
- };
28
-
29
- /**
30
- * Long cache for rarely changing data (config, metadata)
31
- */
32
- export const LONG_CACHE_CONFIG = {
33
- gcTime: 1000 * 60 * 30, // 30 minutes
34
- staleTime: 1000 * 60 * 10, // 10 minutes
35
- refetchOnMount: false,
36
- refetchOnWindowFocus: false,
37
- refetchOnReconnect: true,
38
- };