@umituz/react-native-subscription 2.43.5 → 2.43.7
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 +1 -1
- package/src/domains/credits/application/DeductCreditsCommand.ts +0 -3
- package/src/domains/credits/application/RefundCreditsCommand.ts +0 -3
- package/src/domains/credits/presentation/deduct-credit/mutationConfig.ts +2 -2
- package/src/domains/credits/presentation/deduct-credit/useDeductCredit.ts +2 -2
- package/src/domains/credits/presentation/useCredits.ts +10 -50
- package/src/domains/credits/presentation/useCreditsRealTime.ts +115 -0
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +0 -8
- package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +1 -11
- package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +1 -11
- package/src/domains/subscription/infrastructure/hooks/useSubscriptionPackages.ts +41 -39
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +0 -2
- package/src/domains/subscription/presentation/useSubscriptionStatus.ts +34 -47
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +59 -33
- package/src/domains/subscription/infrastructure/hooks/subscriptionQueryKeys.ts +0 -6
- package/src/shared/infrastructure/react-query/hooks/usePreviousUserCleanup.ts +0 -22
- package/src/shared/infrastructure/react-query/queryConfig.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.43.
|
|
3
|
+
"version": "2.43.7",
|
|
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,7 +1,6 @@
|
|
|
1
1
|
import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
2
2
|
import type { DeductCreditsResult } from "../core/Credits";
|
|
3
3
|
import { CREDIT_ERROR_CODES, MAX_SINGLE_DEDUCTION } from "../core/CreditsConstants";
|
|
4
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
4
|
|
|
6
5
|
export async function deductCreditsOperation(
|
|
7
6
|
_db: Firestore,
|
|
@@ -65,8 +64,6 @@ export async function deductCreditsOperation(
|
|
|
65
64
|
|
|
66
65
|
if (__DEV__) console.log('[DeductCreditsCommand] transaction SUCCESS, remaining:', remaining);
|
|
67
66
|
|
|
68
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
69
|
-
|
|
70
67
|
return {
|
|
71
68
|
success: true,
|
|
72
69
|
remainingCredits: remaining,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
2
2
|
import type { DeductCreditsResult } from "../core/Credits";
|
|
3
3
|
import { CREDIT_ERROR_CODES } from "../core/CreditsConstants";
|
|
4
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
4
|
|
|
6
5
|
export async function refundCreditsOperation(
|
|
7
6
|
_db: Firestore,
|
|
@@ -53,8 +52,6 @@ export async function refundCreditsOperation(
|
|
|
53
52
|
return updated;
|
|
54
53
|
});
|
|
55
54
|
|
|
56
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
57
|
-
|
|
58
55
|
return {
|
|
59
56
|
success: true,
|
|
60
57
|
remainingCredits: remaining,
|
|
@@ -72,7 +72,7 @@ export function createDeductCreditMutationConfig(
|
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
|
-
// onSuccess removed -
|
|
76
|
-
// Optimistic update already applied,
|
|
75
|
+
// onSuccess removed - real-time sync (onSnapshot) handles automatic updates
|
|
76
|
+
// Optimistic update already applied, real-time listener will confirm actual value
|
|
77
77
|
};
|
|
78
78
|
}
|
|
@@ -56,7 +56,7 @@ export const useDeductCredit = ({
|
|
|
56
56
|
try {
|
|
57
57
|
const result = await repository.refundCredit(userId, amount);
|
|
58
58
|
if (result.success) {
|
|
59
|
-
//
|
|
59
|
+
// Real-time sync (onSnapshot) handles automatic update
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
62
|
return false;
|
|
@@ -70,7 +70,7 @@ export const useDeductCredit = ({
|
|
|
70
70
|
}
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
|
-
}, [userId, repository
|
|
73
|
+
}, [userId, repository]);
|
|
74
74
|
|
|
75
75
|
return {
|
|
76
76
|
checkCredits,
|
|
@@ -1,27 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useCallback, useMemo, useEffect } from "react";
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
3
2
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
4
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
|
-
import { SHORT_CACHE_CONFIG } from "../../../shared/infrastructure/react-query/queryConfig";
|
|
6
|
-
import { usePreviousUserCleanup } from "../../../shared/infrastructure/react-query/hooks/usePreviousUserCleanup";
|
|
7
3
|
import {
|
|
8
|
-
getCreditsRepository,
|
|
9
4
|
getCreditsConfig,
|
|
10
5
|
isCreditsRepositoryConfigured,
|
|
11
6
|
} from "../infrastructure/CreditsRepositoryManager";
|
|
12
7
|
import { calculateSafePercentage, canAffordAmount } from "../utils/creditValidation";
|
|
13
8
|
import { isAuthenticated } from "../../subscription/utils/authGuards";
|
|
14
|
-
import { creditsQueryKeys } from "./creditsQueryKeys";
|
|
15
9
|
import type { UseCreditsResult, CreditsLoadStatus } from "./useCredits.types";
|
|
16
|
-
import
|
|
10
|
+
import { useCreditsRealTime } from "./useCreditsRealTime";
|
|
17
11
|
|
|
18
12
|
const deriveLoadStatus = (
|
|
19
|
-
|
|
13
|
+
isLoading: boolean,
|
|
14
|
+
error: Error | null,
|
|
20
15
|
queryEnabled: boolean
|
|
21
16
|
): CreditsLoadStatus => {
|
|
22
17
|
if (!queryEnabled) return "idle";
|
|
23
|
-
if (
|
|
24
|
-
if (
|
|
18
|
+
if (isLoading) return "loading";
|
|
19
|
+
if (error) return "error";
|
|
25
20
|
return "ready";
|
|
26
21
|
};
|
|
27
22
|
|
|
@@ -33,41 +28,7 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
33
28
|
const hasUser = isAuthenticated(userId);
|
|
34
29
|
const queryEnabled = hasUser && isConfigured;
|
|
35
30
|
|
|
36
|
-
const {
|
|
37
|
-
queryKey: creditsQueryKeys.user(userId),
|
|
38
|
-
queryFn: async () => {
|
|
39
|
-
if (!hasUser || !isConfigured) return null;
|
|
40
|
-
|
|
41
|
-
const repository = getCreditsRepository();
|
|
42
|
-
const result = await repository.getCredits(userId);
|
|
43
|
-
|
|
44
|
-
if (!result.success) {
|
|
45
|
-
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return result.data ?? null;
|
|
49
|
-
},
|
|
50
|
-
enabled: queryEnabled,
|
|
51
|
-
...SHORT_CACHE_CONFIG,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const queryClient = useQueryClient();
|
|
55
|
-
|
|
56
|
-
usePreviousUserCleanup(userId, queryClient, creditsQueryKeys.user);
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (!hasUser) return undefined;
|
|
60
|
-
|
|
61
|
-
const unsubscribe = subscriptionEventBus.on(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, (updatedUserId) => {
|
|
62
|
-
if (updatedUserId === userId) {
|
|
63
|
-
queryClient.invalidateQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
return unsubscribe;
|
|
68
|
-
}, [userId, hasUser, queryClient]);
|
|
69
|
-
|
|
70
|
-
const credits = data ?? null;
|
|
31
|
+
const { credits, isLoading, error } = useCreditsRealTime(userId);
|
|
71
32
|
|
|
72
33
|
const derivedValues = useMemo(() => {
|
|
73
34
|
const has = (credits?.credits ?? 0) > 0;
|
|
@@ -81,19 +42,18 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
81
42
|
[credits]
|
|
82
43
|
);
|
|
83
44
|
|
|
84
|
-
const loadStatus = deriveLoadStatus(
|
|
45
|
+
const loadStatus = deriveLoadStatus(isLoading, error, queryEnabled);
|
|
85
46
|
const isCreditsLoaded = loadStatus === "ready";
|
|
86
|
-
const isLoading = loadStatus === "loading";
|
|
87
47
|
|
|
88
48
|
return {
|
|
89
49
|
credits,
|
|
90
50
|
isLoading,
|
|
91
51
|
isCreditsLoaded,
|
|
92
52
|
loadStatus,
|
|
93
|
-
error
|
|
53
|
+
error,
|
|
94
54
|
hasCredits: derivedValues.hasCredits,
|
|
95
55
|
creditsPercent: derivedValues.creditsPercent,
|
|
96
|
-
refetch,
|
|
56
|
+
refetch: async () => {},
|
|
97
57
|
canAfford,
|
|
98
58
|
};
|
|
99
59
|
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { onSnapshot } from "firebase/firestore";
|
|
3
|
+
import type { UserCredits } from "../core/Credits";
|
|
4
|
+
import { getCreditsConfig } from "../infrastructure/CreditsRepositoryManager";
|
|
5
|
+
import { mapCreditsDocumentToEntity } from "../core/CreditsMapper";
|
|
6
|
+
import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore/collectionUtils";
|
|
7
|
+
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Real-time sync for credits using Firestore onSnapshot.
|
|
11
|
+
* Provides instant updates without cache invalidation complexity.
|
|
12
|
+
*
|
|
13
|
+
* Benefits:
|
|
14
|
+
* - Zero cache invalidation needed
|
|
15
|
+
* - Instant updates from Firestore
|
|
16
|
+
* - Always consistent with server state
|
|
17
|
+
* - Simpler code (no event listeners needed)
|
|
18
|
+
*
|
|
19
|
+
* @param userId - User ID to sync credits for
|
|
20
|
+
* @returns Credits state and loading status
|
|
21
|
+
*/
|
|
22
|
+
export function useCreditsRealTime(userId: string | null | undefined) {
|
|
23
|
+
const [credits, setCredits] = useState<UserCredits | null>(null);
|
|
24
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
25
|
+
const [error, setError] = useState<Error | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Reset state when userId changes
|
|
29
|
+
if (!userId) {
|
|
30
|
+
setCredits(null);
|
|
31
|
+
setIsLoading(false);
|
|
32
|
+
setError(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setIsLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const db = requireFirestore();
|
|
41
|
+
const config = getCreditsConfig();
|
|
42
|
+
|
|
43
|
+
// Build doc ref using same logic as repository
|
|
44
|
+
const collectionConfig: CollectionConfig = {
|
|
45
|
+
collectionName: config.collectionName,
|
|
46
|
+
useUserSubcollection: config.useUserSubcollection,
|
|
47
|
+
};
|
|
48
|
+
const docRef = buildDocRef(db, userId, "balance", collectionConfig);
|
|
49
|
+
|
|
50
|
+
// Real-time listener
|
|
51
|
+
const unsubscribe = onSnapshot(
|
|
52
|
+
docRef,
|
|
53
|
+
(snapshot) => {
|
|
54
|
+
if (snapshot.exists()) {
|
|
55
|
+
const entity = mapCreditsDocumentToEntity(snapshot.data() as UserCreditsDocumentRead);
|
|
56
|
+
setCredits(entity);
|
|
57
|
+
} else {
|
|
58
|
+
setCredits(null);
|
|
59
|
+
}
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
},
|
|
62
|
+
(err) => {
|
|
63
|
+
console.error("[useCreditsRealTime] Snapshot error:", err);
|
|
64
|
+
setError(err as Error);
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
unsubscribe();
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
74
|
+
console.error("[useCreditsRealTime] Setup error:", err);
|
|
75
|
+
setError(error);
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}
|
|
78
|
+
}, [userId]);
|
|
79
|
+
|
|
80
|
+
const refetch = useCallback(() => {
|
|
81
|
+
// Real-time sync doesn't need refetch, but keep for API compatibility
|
|
82
|
+
// The snapshot listener will automatically update when data changes
|
|
83
|
+
if (__DEV__) {
|
|
84
|
+
console.warn("[useCreditsRealTime] Refetch called - not needed for real-time sync");
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
credits,
|
|
90
|
+
isLoading,
|
|
91
|
+
error,
|
|
92
|
+
refetch, // No-op but kept for compatibility
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Hook to get derived credit values with real-time sync.
|
|
98
|
+
* This is the real-time equivalent of the computed values in useCredits.
|
|
99
|
+
*/
|
|
100
|
+
export function useCreditsRealTimeDerived(userId: string | null | undefined) {
|
|
101
|
+
const { credits, isLoading } = useCreditsRealTime(userId);
|
|
102
|
+
|
|
103
|
+
const hasCredits = (credits?.credits ?? 0) > 0;
|
|
104
|
+
const creditsPercent = credits ? Math.min(
|
|
105
|
+
(credits.credits / credits.creditLimit) * 100,
|
|
106
|
+
100
|
|
107
|
+
) : 0;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
hasCredits,
|
|
111
|
+
creditsPercent,
|
|
112
|
+
isLoading,
|
|
113
|
+
credits,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -234,7 +234,6 @@ export class SubscriptionSyncProcessor {
|
|
|
234
234
|
throw new Error(`[SubscriptionSyncProcessor] Credit initialization failed for purchase: ${result.error?.message ?? 'unknown'}`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
this.emitCreditsUpdated(creditsUserId);
|
|
238
237
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
239
238
|
console.log('[SubscriptionSyncProcessor] 🟢 processPurchase: Credits initialized successfully', {
|
|
240
239
|
creditsUserId,
|
|
@@ -271,7 +270,6 @@ export class SubscriptionSyncProcessor {
|
|
|
271
270
|
throw new Error(`[SubscriptionSyncProcessor] Credit initialization failed for renewal: ${result.error?.message ?? 'unknown'}`);
|
|
272
271
|
}
|
|
273
272
|
|
|
274
|
-
this.emitCreditsUpdated(creditsUserId);
|
|
275
273
|
} finally {
|
|
276
274
|
this.purchaseInProgress = false;
|
|
277
275
|
}
|
|
@@ -315,7 +313,6 @@ export class SubscriptionSyncProcessor {
|
|
|
315
313
|
|
|
316
314
|
private async expireSubscription(userId: string): Promise<void> {
|
|
317
315
|
await getCreditsRepository().syncExpiredStatus(userId);
|
|
318
|
-
this.emitCreditsUpdated(userId);
|
|
319
316
|
}
|
|
320
317
|
|
|
321
318
|
private async syncPremiumStatus(userId: string, event: PremiumStatusChangedEvent): Promise<void> {
|
|
@@ -357,7 +354,6 @@ export class SubscriptionSyncProcessor {
|
|
|
357
354
|
store: event.store ?? null,
|
|
358
355
|
ownershipType: event.ownershipType ?? null,
|
|
359
356
|
});
|
|
360
|
-
this.emitCreditsUpdated(userId);
|
|
361
357
|
|
|
362
358
|
if (__DEV__) {
|
|
363
359
|
console.log('[SubscriptionSyncProcessor] 🟢 syncPremiumStatus: Completed', {
|
|
@@ -367,8 +363,4 @@ export class SubscriptionSyncProcessor {
|
|
|
367
363
|
});
|
|
368
364
|
}
|
|
369
365
|
}
|
|
370
|
-
|
|
371
|
-
private emitCreditsUpdated(userId: string): void {
|
|
372
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
373
|
-
}
|
|
374
366
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMutation
|
|
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 are invalidated via events:
|
|
45
|
-
// - CREDITS_UPDATED event (SubscriptionSyncProcessor → useCredits)
|
|
46
|
-
// - PREMIUM_STATUS_CHANGED event (SubscriptionSyncProcessor → useSubscriptionStatus)
|
|
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
|
|
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 are invalidated via events:
|
|
36
|
-
// - CREDITS_UPDATED event (SubscriptionSyncProcessor → useCredits)
|
|
37
|
-
// - PREMIUM_STATUS_CHANGED event (SubscriptionSyncProcessor → useSubscriptionStatus)
|
|
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 {
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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,
|
|
58
|
+
}, [userId, fetchPackages]);
|
|
59
|
+
|
|
60
|
+
const refetch = useCallback(() => {
|
|
61
|
+
fetchPackages();
|
|
62
|
+
}, [fetchPackages]);
|
|
66
63
|
|
|
67
|
-
return
|
|
64
|
+
return {
|
|
65
|
+
data: packages,
|
|
66
|
+
isLoading,
|
|
67
|
+
error,
|
|
68
|
+
refetch,
|
|
69
|
+
};
|
|
68
70
|
};
|
|
@@ -7,7 +7,6 @@ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
|
7
7
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
8
8
|
import { ScreenLayout } from "../../../../shared/presentation/layouts/ScreenLayout";
|
|
9
9
|
import { getCreditsRepository } from "../../../credits/infrastructure/CreditsRepositoryManager";
|
|
10
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../../shared/infrastructure/SubscriptionEventBus";
|
|
11
10
|
import { SubscriptionHeader } from "./components/SubscriptionHeader";
|
|
12
11
|
import { CreditsList } from "./components/CreditsList";
|
|
13
12
|
import { UpgradePrompt } from "./components/UpgradePrompt";
|
|
@@ -116,7 +115,6 @@ const DevTestPanel: React.FC<{ statusType: string }> = ({ statusType }) => {
|
|
|
116
115
|
const userId = selectUserId(useAuthStore.getState());
|
|
117
116
|
if (!userId) throw new Error("No userId found");
|
|
118
117
|
await getCreditsRepository().syncExpiredStatus(userId);
|
|
119
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
120
118
|
}), [run]);
|
|
121
119
|
|
|
122
120
|
const handleRestore = useCallback(() => run("Restore", async () => {
|
|
@@ -1,26 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
queryFn: async () => {
|
|
39
|
-
if (!hasUser) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
setError(null);
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
queryKey: subscriptionStatusQueryKeys.user(userId),
|
|
60
|
-
});
|
|
61
|
+
fetchStatus();
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
);
|
|
64
65
|
|
|
65
66
|
return unsubscribe;
|
|
66
|
-
}, [userId, hasUser,
|
|
67
|
-
|
|
68
|
-
const isLoading = status === "pending";
|
|
67
|
+
}, [userId, hasUser, fetchStatus]);
|
|
69
68
|
|
|
70
69
|
return {
|
|
71
|
-
|
|
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
|
|
86
|
-
refetch,
|
|
87
|
-
};
|
|
72
|
+
error,
|
|
73
|
+
refetch: fetchStatus,
|
|
74
|
+
} as SubscriptionStatusResult;
|
|
88
75
|
};
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
3
2
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
36
|
-
()
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!userId) {
|
|
34
|
+
setTransactions([]);
|
|
35
|
+
setIsLoading(false);
|
|
36
|
+
setError(null);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
setIsLoading(true);
|
|
41
|
+
setError(null);
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
try {
|
|
44
|
+
const db = requireFirestore();
|
|
45
|
+
const collectionPath = config.useUserSubcollection
|
|
46
|
+
? `users/${userId}/${config.collectionName}`
|
|
47
|
+
: config.collectionName;
|
|
46
48
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const q = query(
|
|
50
|
+
collection(db, collectionPath),
|
|
51
|
+
orderBy("timestamp", "desc"),
|
|
52
|
+
limit(limitCount)
|
|
53
|
+
) as Query;
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
93
|
+
error,
|
|
68
94
|
refetch,
|
|
69
95
|
isEmpty: transactions.length === 0,
|
|
70
96
|
};
|
|
@@ -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
|
-
};
|