@umituz/react-native-subscription 2.27.88 → 2.27.91
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/presentation/useCredits.ts +7 -29
- package/src/domains/credits/presentation/useDeductCredit.ts +7 -8
- package/src/domains/credits/utils/creditCalculations.ts +33 -0
- package/src/presentation/hooks/README.md +1 -1
- package/src/presentation/hooks/usePremium.md +2 -2
- package/src/utils/index.ts +1 -3
- package/src/utils/packageTypeDetector.ts +8 -18
- package/src/utils/premiumStatusUtils.ts +4 -4
- package/src/utils/tierUtils.ts +8 -8
- package/src/utils/types.ts +4 -4
- package/src/utils/validation.ts +2 -2
- package/src/utils/__tests__/authUtils.test.ts +0 -37
- package/src/utils/__tests__/edgeCases.test.ts +0 -79
- package/src/utils/__tests__/premiumUtils.test.ts +0 -89
- package/src/utils/__tests__/tierUtils.test.ts +0 -74
- package/src/utils/__tests__/validation.test.ts +0 -105
- package/src/utils/aiCreditHelpers.ts +0 -113
- package/src/utils/authUtils.ts +0 -19
- package/src/utils/creditChecker.ts +0 -82
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.91",
|
|
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",
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getCreditsConfig,
|
|
16
16
|
isCreditsRepositoryConfigured,
|
|
17
17
|
} from "../infrastructure/CreditsRepositoryProvider";
|
|
18
|
+
import { calculateCreditPercentage, canAffordCost } from "../utils/creditCalculations";
|
|
18
19
|
|
|
19
20
|
export const creditsQueryKeys = {
|
|
20
21
|
all: ["credits"] as const,
|
|
@@ -49,14 +50,8 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
49
50
|
const userId = useAuthStore(selectUserId);
|
|
50
51
|
const isConfigured = isCreditsRepositoryConfigured();
|
|
51
52
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
config = isConfigured ? getCreditsConfig() : { creditLimit: 200 }; // Default fallback
|
|
56
|
-
} catch (e) {
|
|
57
|
-
config = { creditLimit: 200 };
|
|
58
|
-
}
|
|
59
|
-
|
|
53
|
+
// Only access config if configured to avoid throwing errors
|
|
54
|
+
const config = isConfigured ? getCreditsConfig() : null;
|
|
60
55
|
const queryEnabled = !!userId && isConfigured;
|
|
61
56
|
|
|
62
57
|
const { data, status, error, refetch } = useQuery({
|
|
@@ -71,24 +66,6 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
71
66
|
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
// If subscription is expired, immediately return 0 credits
|
|
75
|
-
// to prevent any window where expired user could deduct
|
|
76
|
-
if (result.data?.status === "expired") {
|
|
77
|
-
// Sync to Firestore in background
|
|
78
|
-
repository.syncExpiredStatus(userId).catch((syncError) => {
|
|
79
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
80
|
-
console.warn("[useCredits] Background sync failed:", syncError);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Return expired data with 0 credits immediately
|
|
85
|
-
return {
|
|
86
|
-
...result.data,
|
|
87
|
-
credits: 0,
|
|
88
|
-
isPremium: false,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
69
|
return result.data || null;
|
|
93
70
|
},
|
|
94
71
|
enabled: queryEnabled,
|
|
@@ -119,12 +96,13 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
119
96
|
|
|
120
97
|
const derivedValues = useMemo(() => {
|
|
121
98
|
const has = (credits?.credits ?? 0) > 0;
|
|
122
|
-
const
|
|
99
|
+
const limit = config?.creditLimit ?? 0;
|
|
100
|
+
const percent = calculateCreditPercentage(credits?.credits, limit);
|
|
123
101
|
return { hasCredits: has, creditsPercent: percent };
|
|
124
|
-
}, [credits, config
|
|
102
|
+
}, [credits, config?.creditLimit]);
|
|
125
103
|
|
|
126
104
|
const canAfford = useCallback(
|
|
127
|
-
(cost: number): boolean => (credits?.credits
|
|
105
|
+
(cost: number): boolean => canAffordCost(credits?.credits, cost),
|
|
128
106
|
[credits]
|
|
129
107
|
);
|
|
130
108
|
|
|
@@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from "@umituz/react-native-design-system"
|
|
|
8
8
|
import type { UserCredits } from "../core/Credits";
|
|
9
9
|
import { getCreditsRepository } from "../infrastructure/CreditsRepositoryProvider";
|
|
10
10
|
import { creditsQueryKeys } from "./useCredits";
|
|
11
|
+
import { calculateRemainingCredits } from "../utils/creditCalculations";
|
|
11
12
|
|
|
12
13
|
import { timezoneService } from "@umituz/react-native-design-system";
|
|
13
14
|
|
|
@@ -42,14 +43,12 @@ export const useDeductCredit = ({
|
|
|
42
43
|
await queryClient.cancelQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
43
44
|
const previousCredits = queryClient.getQueryData<UserCredits>(creditsQueryKeys.user(userId));
|
|
44
45
|
|
|
45
|
-
// Improved optimistic update logic
|
|
46
46
|
if (!previousCredits) {
|
|
47
47
|
return { previousCredits: null, skippedOptimistic: true };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const newCredits = Math.max(0, previousCredits.credits - cost);
|
|
50
|
+
// Calculate new credits using utility
|
|
51
|
+
const newCredits = calculateRemainingCredits(previousCredits.credits, cost);
|
|
53
52
|
|
|
54
53
|
queryClient.setQueryData<UserCredits | null>(creditsQueryKeys.user(userId), (old) => {
|
|
55
54
|
if (!old) return old;
|
|
@@ -67,10 +66,10 @@ export const useDeductCredit = ({
|
|
|
67
66
|
};
|
|
68
67
|
},
|
|
69
68
|
onError: (_err, _cost, context) => {
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
if (userId && context?.previousCredits && !context.skippedOptimistic
|
|
73
|
-
|
|
69
|
+
// Always restore previous credits on error to prevent UI desync
|
|
70
|
+
// Use optional chaining to be safe
|
|
71
|
+
if (userId && context?.previousCredits && !context.skippedOptimistic) {
|
|
72
|
+
queryClient.setQueryData(creditsQueryKeys.user(userId), context.previousCredits);
|
|
74
73
|
}
|
|
75
74
|
},
|
|
76
75
|
onSuccess: () => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit Calculation Utilities
|
|
3
|
+
* Centralized logic for credit mathematical operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const calculateCreditPercentage = (
|
|
7
|
+
currentCredits: number | null | undefined,
|
|
8
|
+
creditLimit: number
|
|
9
|
+
): number => {
|
|
10
|
+
if (currentCredits === null || currentCredits === undefined || creditLimit <= 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const percent = Math.round((currentCredits / creditLimit) * 100);
|
|
15
|
+
return Math.min(Math.max(percent, 0), 100); // Clamp between 0-100
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const canAffordCost = (
|
|
19
|
+
currentCredits: number | null | undefined,
|
|
20
|
+
cost: number
|
|
21
|
+
): boolean => {
|
|
22
|
+
if (currentCredits === null || currentCredits === undefined) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return currentCredits >= cost;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const calculateRemainingCredits = (
|
|
29
|
+
currentCredits: number,
|
|
30
|
+
cost: number
|
|
31
|
+
): number => {
|
|
32
|
+
return Math.max(0, currentCredits - cost);
|
|
33
|
+
};
|
|
@@ -103,7 +103,7 @@ All hooks follow consistent patterns:
|
|
|
103
103
|
- **ALWAYS** handle loading and error states
|
|
104
104
|
- **NEVER** trust client-side state for security
|
|
105
105
|
- **MUST** implement error boundaries
|
|
106
|
-
- **ALWAYS** test with various user states (
|
|
106
|
+
- **ALWAYS** test with various user states (anonymous, free, premium)
|
|
107
107
|
|
|
108
108
|
## AI Agent Guidelines
|
|
109
109
|
|
|
@@ -40,7 +40,7 @@ Hook for checking and managing premium subscription status.
|
|
|
40
40
|
|
|
41
41
|
- **NEVER** use for security decisions (server-side validation required)
|
|
42
42
|
- **NEVER** assume instant data availability (always check loading state)
|
|
43
|
-
- **DO NOT** use for
|
|
43
|
+
- **DO NOT** use for anonymous users without proper handling
|
|
44
44
|
|
|
45
45
|
### CRITICAL SAFETY
|
|
46
46
|
|
|
@@ -67,7 +67,7 @@ Hook for checking and managing premium subscription status.
|
|
|
67
67
|
- [ ] Provide upgrade path for free users
|
|
68
68
|
- [ ] Test with premium user
|
|
69
69
|
- [ ] Test with free user
|
|
70
|
-
- [ ] Test with
|
|
70
|
+
- [ ] Test with anonymous user
|
|
71
71
|
- [ ] Test offline scenario
|
|
72
72
|
|
|
73
73
|
### Common Patterns
|
package/src/utils/index.ts
CHANGED
|
@@ -16,8 +16,10 @@ export type SubscriptionPackageType = PackageType;
|
|
|
16
16
|
* Credit packages use a different system and don't need type detection
|
|
17
17
|
*/
|
|
18
18
|
export function isCreditPackage(identifier: string): boolean {
|
|
19
|
+
if (!identifier) return false;
|
|
19
20
|
// Matches "credit" as a word or part of a common naming pattern
|
|
20
|
-
|
|
21
|
+
// More strict to avoid false positives (e.g. "accredited")
|
|
22
|
+
return /(?:^|[._-])credit(?:$|[._-])/i.test(identifier);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -36,37 +38,25 @@ export function detectPackageType(productIdentifier: string): SubscriptionPackag
|
|
|
36
38
|
return PACKAGE_TYPE.UNKNOWN;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
// Preview API mode (Expo Go testing)
|
|
40
|
-
if (normalized.includes("preview")) {
|
|
41
|
-
if (__DEV__) {
|
|
42
|
-
console.log("[PackageTypeDetector] Detected: PREVIEW (monthly)");
|
|
43
|
-
}
|
|
44
|
-
return PACKAGE_TYPE.MONTHLY;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
// Weekly detection: matches "weekly" or "week" as distinct parts of the ID
|
|
48
42
|
if (/\bweekly?\b|_week_|-week-|\.week\./i.test(normalized)) {
|
|
49
|
-
if (__DEV__) {
|
|
50
|
-
console.log("[PackageTypeDetector] Detected: WEEKLY");
|
|
51
|
-
}
|
|
52
43
|
return PACKAGE_TYPE.WEEKLY;
|
|
53
44
|
}
|
|
54
45
|
|
|
55
46
|
// Monthly detection: matches "monthly" or "month"
|
|
56
47
|
if (/\bmonthly?\b|_month_|-month-|\.month\./i.test(normalized)) {
|
|
57
|
-
if (__DEV__) {
|
|
58
|
-
console.log("[PackageTypeDetector] Detected: MONTHLY");
|
|
59
|
-
}
|
|
60
48
|
return PACKAGE_TYPE.MONTHLY;
|
|
61
49
|
}
|
|
62
50
|
|
|
63
51
|
// Yearly detection: matches "yearly", "year", or "annual"
|
|
64
52
|
if (/\byearly?\b|_year_|-year-|\.year\.|annual/i.test(normalized)) {
|
|
65
|
-
if (__DEV__) {
|
|
66
|
-
console.log("[PackageTypeDetector] Detected: YEARLY");
|
|
67
|
-
}
|
|
68
53
|
return PACKAGE_TYPE.YEARLY;
|
|
69
54
|
}
|
|
55
|
+
|
|
56
|
+
// Lifetime detection: matches "lifetime"
|
|
57
|
+
if (/\blifetime\b|_lifetime_|-lifetime-|\.lifetime\./i.test(normalized)) {
|
|
58
|
+
return PACKAGE_TYPE.LIFETIME;
|
|
59
|
+
}
|
|
70
60
|
|
|
71
61
|
if (__DEV__) {
|
|
72
62
|
console.warn("[PackageTypeDetector] Unknown package type for:", productIdentifier);
|
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { PremiumStatusFetcher } from './types';
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Get isPremium value with centralized logic
|
|
12
12
|
*/
|
|
13
13
|
export function getIsPremium(
|
|
14
|
-
|
|
14
|
+
isAnonymous: boolean,
|
|
15
15
|
userId: string | null,
|
|
16
16
|
isPremiumOrFetcher: boolean | PremiumStatusFetcher,
|
|
17
17
|
): Promise<boolean> {
|
|
18
|
-
//
|
|
19
|
-
if (
|
|
18
|
+
// Anonymous users NEVER have premium
|
|
19
|
+
if (isAnonymous || userId === null) return Promise.resolve(false);
|
|
20
20
|
|
|
21
21
|
// Sync mode: return the provided isPremium value
|
|
22
22
|
if (typeof isPremiumOrFetcher === 'boolean') return Promise.resolve(isPremiumOrFetcher);
|
package/src/utils/tierUtils.ts
CHANGED
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { UserTierInfo } from './types';
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
export function getUserTierInfo(
|
|
11
|
-
|
|
11
|
+
isAnonymous: boolean,
|
|
12
12
|
userId: string | null,
|
|
13
13
|
isPremium: boolean,
|
|
14
14
|
): UserTierInfo {
|
|
15
|
-
if (
|
|
15
|
+
if (isAnonymous || userId === null) {
|
|
16
16
|
return {
|
|
17
|
-
tier: '
|
|
17
|
+
tier: 'anonymous',
|
|
18
18
|
isPremium: false,
|
|
19
|
-
|
|
19
|
+
isAnonymous: true,
|
|
20
20
|
isAuthenticated: false,
|
|
21
21
|
userId: null,
|
|
22
22
|
};
|
|
@@ -25,17 +25,17 @@ export function getUserTierInfo(
|
|
|
25
25
|
return {
|
|
26
26
|
tier: isPremium ? 'premium' : 'freemium',
|
|
27
27
|
isPremium,
|
|
28
|
-
|
|
28
|
+
isAnonymous: false,
|
|
29
29
|
isAuthenticated: true,
|
|
30
30
|
userId,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function checkPremiumAccess(
|
|
35
|
-
|
|
35
|
+
isAnonymous: boolean,
|
|
36
36
|
userId: string | null,
|
|
37
37
|
isPremium: boolean,
|
|
38
38
|
): boolean {
|
|
39
|
-
if (
|
|
39
|
+
if (isAnonymous || userId === null) return false;
|
|
40
40
|
return isPremium;
|
|
41
41
|
}
|
package/src/utils/types.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Type definitions for user tier system
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export type UserTier = '
|
|
7
|
+
export type UserTier = 'anonymous' | 'freemium' | 'premium';
|
|
8
8
|
|
|
9
9
|
export interface UserTierInfo {
|
|
10
10
|
/** User tier classification */
|
|
@@ -13,13 +13,13 @@ export interface UserTierInfo {
|
|
|
13
13
|
/** Whether user has premium access */
|
|
14
14
|
isPremium: boolean;
|
|
15
15
|
|
|
16
|
-
/** Whether user is
|
|
17
|
-
|
|
16
|
+
/** Whether user is anonymous (not authenticated) */
|
|
17
|
+
isAnonymous: boolean;
|
|
18
18
|
|
|
19
19
|
/** Whether user is authenticated */
|
|
20
20
|
isAuthenticated: boolean;
|
|
21
21
|
|
|
22
|
-
/** User ID (null for
|
|
22
|
+
/** User ID (null for anonymous users) */
|
|
23
23
|
userId: string | null;
|
|
24
24
|
}
|
|
25
25
|
|
package/src/utils/validation.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { UserTier, UserTierInfo } from './types';
|
|
8
8
|
|
|
9
9
|
export function isValidUserTier(value: unknown): value is UserTier {
|
|
10
|
-
return value === '
|
|
10
|
+
return value === 'anonymous' || value === 'freemium' || value === 'premium';
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function isUserTierInfo(value: unknown): value is UserTierInfo {
|
|
@@ -16,7 +16,7 @@ export function isUserTierInfo(value: unknown): value is UserTierInfo {
|
|
|
16
16
|
return (
|
|
17
17
|
isValidUserTier(obj.tier) &&
|
|
18
18
|
typeof obj.isPremium === 'boolean' &&
|
|
19
|
-
typeof obj.
|
|
19
|
+
typeof obj.isAnonymous === 'boolean' &&
|
|
20
20
|
typeof obj.isAuthenticated === 'boolean' &&
|
|
21
21
|
(obj.userId === null || typeof obj.userId === 'string')
|
|
22
22
|
);
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Authentication Utilities Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for authentication check functions
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { isAuthenticated, isGuest } from '../authUtils';
|
|
8
|
-
|
|
9
|
-
describe('isAuthenticated', () => {
|
|
10
|
-
it('should return false for guest users', () => {
|
|
11
|
-
expect(isAuthenticated(true, null)).toBe(false);
|
|
12
|
-
expect(isAuthenticated(true, 'user123')).toBe(false);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('should return false when userId is null', () => {
|
|
16
|
-
expect(isAuthenticated(false, null)).toBe(false);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('should return true for authenticated users', () => {
|
|
20
|
-
expect(isAuthenticated(false, 'user123')).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('isGuest', () => {
|
|
25
|
-
it('should return true for guest users', () => {
|
|
26
|
-
expect(isGuest(true, null)).toBe(true);
|
|
27
|
-
expect(isGuest(true, 'user123')).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('should return true when userId is null', () => {
|
|
31
|
-
expect(isGuest(false, null)).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('should return false for authenticated users', () => {
|
|
35
|
-
expect(isGuest(false, 'user123')).toBe(false);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edge Cases Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for edge cases and special scenarios
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { getUserTierInfo } from '../tierUtils';
|
|
8
|
-
import { isAuthenticated, isGuest } from '../authUtils';
|
|
9
|
-
import { validateUserId } from '../validation';
|
|
10
|
-
|
|
11
|
-
describe('Edge Cases', () => {
|
|
12
|
-
describe('User ID validation', () => {
|
|
13
|
-
it('should handle empty string userId as invalid', () => {
|
|
14
|
-
expect(() => validateUserId('')).toThrow(TypeError);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('should handle whitespace-only userId as invalid', () => {
|
|
18
|
-
expect(() => validateUserId(' ')).toThrow(TypeError);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('should handle very long userId strings', () => {
|
|
22
|
-
const longUserId = 'a'.repeat(1000);
|
|
23
|
-
const result = getUserTierInfo(false, longUserId, true);
|
|
24
|
-
expect(result.userId).toBe(longUserId);
|
|
25
|
-
expect(result.tier).toBe('premium');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('should handle special characters in userId', () => {
|
|
29
|
-
const specialUserId = 'user-123_test@example.com';
|
|
30
|
-
const result = getUserTierInfo(false, specialUserId, false);
|
|
31
|
-
expect(result.userId).toBe(specialUserId);
|
|
32
|
-
expect(result.tier).toBe('freemium');
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('Authentication edge cases', () => {
|
|
37
|
-
it('should handle conflicting auth states consistently', () => {
|
|
38
|
-
// isGuest=true but userId provided - should prioritize guest logic
|
|
39
|
-
expect(isAuthenticated(true, 'user123')).toBe(false);
|
|
40
|
-
expect(isGuest(true, 'user123')).toBe(true);
|
|
41
|
-
|
|
42
|
-
const result = getUserTierInfo(true, 'user123', true);
|
|
43
|
-
expect(result.tier).toBe('guest');
|
|
44
|
-
expect(result.isPremium).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('should handle isGuest=false but null userId', () => {
|
|
48
|
-
// isGuest=false but userId=null - should treat as guest
|
|
49
|
-
expect(isAuthenticated(false, null)).toBe(false);
|
|
50
|
-
expect(isGuest(false, null)).toBe(true);
|
|
51
|
-
|
|
52
|
-
const result = getUserTierInfo(false, null, true);
|
|
53
|
-
expect(result.tier).toBe('guest');
|
|
54
|
-
expect(result.isPremium).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('Premium status edge cases', () => {
|
|
59
|
-
it('should ignore isPremium for guest users regardless of value', () => {
|
|
60
|
-
const guestTrue = getUserTierInfo(true, null, true);
|
|
61
|
-
const guestFalse = getUserTierInfo(true, null, false);
|
|
62
|
-
|
|
63
|
-
expect(guestTrue.isPremium).toBe(false);
|
|
64
|
-
expect(guestFalse.isPremium).toBe(false);
|
|
65
|
-
expect(guestTrue.tier).toBe('guest');
|
|
66
|
-
expect(guestFalse.tier).toBe('guest');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should handle authenticated users with various premium states', () => {
|
|
70
|
-
const premium = getUserTierInfo(false, 'user123', true);
|
|
71
|
-
const freemium = getUserTierInfo(false, 'user123', false);
|
|
72
|
-
|
|
73
|
-
expect(premium.tier).toBe('premium');
|
|
74
|
-
expect(premium.isPremium).toBe(true);
|
|
75
|
-
expect(freemium.tier).toBe('freemium');
|
|
76
|
-
expect(freemium.isPremium).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
});
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Premium Utilities Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for premium status fetching and async functions
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { getIsPremium } from '../premiumStatusUtils';
|
|
8
|
-
import type { PremiumStatusFetcher } from '../types';
|
|
9
|
-
|
|
10
|
-
describe('getIsPremium', () => {
|
|
11
|
-
describe('Sync mode (boolean isPremium)', () => {
|
|
12
|
-
it('should return false for guest users', () => {
|
|
13
|
-
const result = getIsPremium(true, null, true);
|
|
14
|
-
expect(result).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('should return false when userId is null', () => {
|
|
18
|
-
const result = getIsPremium(false, null, true);
|
|
19
|
-
expect(result).toBe(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('should return true when isPremium is true', () => {
|
|
23
|
-
const result = getIsPremium(false, 'user123', true);
|
|
24
|
-
expect(result).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('should return false when isPremium is false', () => {
|
|
28
|
-
const result = getIsPremium(false, 'user123', false);
|
|
29
|
-
expect(result).toBe(false);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('Async mode (fetcher)', () => {
|
|
34
|
-
const mockFetcher: PremiumStatusFetcher = {
|
|
35
|
-
isPremium: jest.fn(),
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
jest.clearAllMocks();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('should return false for guest users without calling fetcher', async () => {
|
|
43
|
-
const result = await getIsPremium(true, null, mockFetcher);
|
|
44
|
-
expect(result).toBe(false);
|
|
45
|
-
expect(mockFetcher.isPremium).not.toHaveBeenCalled();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should return false when userId is null without calling fetcher', async () => {
|
|
49
|
-
const result = await getIsPremium(false, null, mockFetcher);
|
|
50
|
-
expect(result).toBe(false);
|
|
51
|
-
expect(mockFetcher.isPremium).not.toHaveBeenCalled();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should call fetcher for authenticated users', async () => {
|
|
55
|
-
(mockFetcher.isPremium as jest.Mock).mockResolvedValue(true);
|
|
56
|
-
|
|
57
|
-
const result = await getIsPremium(false, 'user123', mockFetcher);
|
|
58
|
-
expect(result).toBe(true);
|
|
59
|
-
expect(mockFetcher.isPremium).toHaveBeenCalledWith('user123');
|
|
60
|
-
expect(mockFetcher.isPremium).toHaveBeenCalledTimes(1);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should return false when fetcher returns false', async () => {
|
|
64
|
-
(mockFetcher.isPremium as jest.Mock).mockResolvedValue(false);
|
|
65
|
-
|
|
66
|
-
const result = await getIsPremium(false, 'user123', mockFetcher);
|
|
67
|
-
expect(result).toBe(false);
|
|
68
|
-
expect(mockFetcher.isPremium).toHaveBeenCalledWith('user123');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should throw error when fetcher throws Error', async () => {
|
|
72
|
-
const error = new Error('Database error');
|
|
73
|
-
(mockFetcher.isPremium as jest.Mock).mockRejectedValue(error);
|
|
74
|
-
|
|
75
|
-
await expect(getIsPremium(false, 'user123', mockFetcher)).rejects.toThrow(
|
|
76
|
-
'Failed to fetch premium status: Database error'
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should throw error when fetcher throws non-Error', async () => {
|
|
81
|
-
const error = 'String error';
|
|
82
|
-
(mockFetcher.isPremium as jest.Mock).mockRejectedValue(error);
|
|
83
|
-
|
|
84
|
-
await expect(getIsPremium(false, 'user123', mockFetcher)).rejects.toThrow(
|
|
85
|
-
'Failed to fetch premium status: String error'
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tier Utilities Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for tier determination and comparison functions
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { getUserTierInfo, checkPremiumAccess } from '../tierUtils';
|
|
8
|
-
|
|
9
|
-
describe('getUserTierInfo', () => {
|
|
10
|
-
describe('Guest users', () => {
|
|
11
|
-
it('should return guest tier when isGuest is true', () => {
|
|
12
|
-
const result = getUserTierInfo(true, null, false);
|
|
13
|
-
expect(result.tier).toBe('guest');
|
|
14
|
-
expect(result.isPremium).toBe(false);
|
|
15
|
-
expect(result.isGuest).toBe(true);
|
|
16
|
-
expect(result.isAuthenticated).toBe(false);
|
|
17
|
-
expect(result.userId).toBe(null);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('should return guest tier when userId is null', () => {
|
|
21
|
-
const result = getUserTierInfo(false, null, false);
|
|
22
|
-
expect(result.tier).toBe('guest');
|
|
23
|
-
expect(result.isPremium).toBe(false);
|
|
24
|
-
expect(result.isGuest).toBe(true);
|
|
25
|
-
expect(result.isAuthenticated).toBe(false);
|
|
26
|
-
expect(result.userId).toBe(null);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('should ignore isPremium for guest users', () => {
|
|
30
|
-
const result = getUserTierInfo(true, null, true);
|
|
31
|
-
expect(result.tier).toBe('guest');
|
|
32
|
-
expect(result.isPremium).toBe(false);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('Authenticated users', () => {
|
|
37
|
-
it('should return premium tier for authenticated premium users', () => {
|
|
38
|
-
const result = getUserTierInfo(false, 'user123', true);
|
|
39
|
-
expect(result.tier).toBe('premium');
|
|
40
|
-
expect(result.isPremium).toBe(true);
|
|
41
|
-
expect(result.isGuest).toBe(false);
|
|
42
|
-
expect(result.isAuthenticated).toBe(true);
|
|
43
|
-
expect(result.userId).toBe('user123');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('should return freemium tier for authenticated non-premium users', () => {
|
|
47
|
-
const result = getUserTierInfo(false, 'user123', false);
|
|
48
|
-
expect(result.tier).toBe('freemium');
|
|
49
|
-
expect(result.isPremium).toBe(false);
|
|
50
|
-
expect(result.isGuest).toBe(false);
|
|
51
|
-
expect(result.isAuthenticated).toBe(true);
|
|
52
|
-
expect(result.userId).toBe('user123');
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('checkPremiumAccess', () => {
|
|
58
|
-
it('should return false for guest users', () => {
|
|
59
|
-
expect(checkPremiumAccess(true, null, true)).toBe(false);
|
|
60
|
-
expect(checkPremiumAccess(true, null, false)).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should return false when userId is null', () => {
|
|
64
|
-
expect(checkPremiumAccess(false, null, true)).toBe(false);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should return true for authenticated premium users', () => {
|
|
68
|
-
expect(checkPremiumAccess(false, 'user123', true)).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should return false for authenticated freemium users', () => {
|
|
72
|
-
expect(checkPremiumAccess(false, 'user123', false)).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* User Tier Validation Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for validation functions and type guards
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
isValidUserTier,
|
|
9
|
-
isUserTierInfo,
|
|
10
|
-
validateUserId,
|
|
11
|
-
validateIsGuest,
|
|
12
|
-
validateIsPremium,
|
|
13
|
-
validateFetcher,
|
|
14
|
-
} from '../validation';
|
|
15
|
-
import type { UserTierInfo, PremiumStatusFetcher } from '../types';
|
|
16
|
-
|
|
17
|
-
describe('isValidUserTier', () => {
|
|
18
|
-
it('should return true for valid tiers', () => {
|
|
19
|
-
expect(isValidUserTier('guest')).toBe(true);
|
|
20
|
-
expect(isValidUserTier('freemium')).toBe(true);
|
|
21
|
-
expect(isValidUserTier('premium')).toBe(true);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('should return false for invalid values', () => {
|
|
25
|
-
expect(isValidUserTier('invalid')).toBe(false);
|
|
26
|
-
expect(isValidUserTier('')).toBe(false);
|
|
27
|
-
expect(isValidUserTier(null)).toBe(false);
|
|
28
|
-
expect(isValidUserTier(undefined)).toBe(false);
|
|
29
|
-
expect(isValidUserTier(123)).toBe(false);
|
|
30
|
-
expect(isValidUserTier({})).toBe(false);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe('isUserTierInfo', () => {
|
|
35
|
-
it('should return true for valid UserTierInfo', () => {
|
|
36
|
-
const validInfo: UserTierInfo = {
|
|
37
|
-
tier: 'premium',
|
|
38
|
-
isPremium: true,
|
|
39
|
-
isGuest: false,
|
|
40
|
-
isAuthenticated: true,
|
|
41
|
-
userId: 'user123',
|
|
42
|
-
};
|
|
43
|
-
expect(isUserTierInfo(validInfo)).toBe(true);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('should return false for invalid objects', () => {
|
|
47
|
-
expect(isUserTierInfo(null)).toBe(false);
|
|
48
|
-
expect(isUserTierInfo(undefined)).toBe(false);
|
|
49
|
-
expect(isUserTierInfo('string')).toBe(false);
|
|
50
|
-
expect(isUserTierInfo({})).toBe(false);
|
|
51
|
-
expect(isUserTierInfo({ tier: 'invalid' })).toBe(false);
|
|
52
|
-
expect(isUserTierInfo({ tier: 'premium' })).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe('validateUserId', () => {
|
|
57
|
-
it('should not throw for valid userId', () => {
|
|
58
|
-
expect(() => validateUserId('user123')).not.toThrow();
|
|
59
|
-
expect(() => validateUserId(null)).not.toThrow();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('should throw for invalid userId', () => {
|
|
63
|
-
expect(() => validateUserId('')).toThrow(TypeError);
|
|
64
|
-
expect(() => validateUserId(' ')).toThrow(TypeError);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('validateIsGuest', () => {
|
|
69
|
-
it('should not throw for valid isGuest', () => {
|
|
70
|
-
expect(() => validateIsGuest(true)).not.toThrow();
|
|
71
|
-
expect(() => validateIsGuest(false)).not.toThrow();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should throw for invalid isGuest', () => {
|
|
75
|
-
expect(() => validateIsGuest('true' as unknown as boolean)).toThrow(TypeError);
|
|
76
|
-
expect(() => validateIsGuest(1 as unknown as boolean)).toThrow(TypeError);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('validateIsPremium', () => {
|
|
81
|
-
it('should not throw for valid isPremium', () => {
|
|
82
|
-
expect(() => validateIsPremium(true)).not.toThrow();
|
|
83
|
-
expect(() => validateIsPremium(false)).not.toThrow();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should throw for invalid isPremium', () => {
|
|
87
|
-
expect(() => validateIsPremium('true' as unknown as boolean)).toThrow(TypeError);
|
|
88
|
-
expect(() => validateIsPremium(1 as unknown as boolean)).toThrow(TypeError);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('validateFetcher', () => {
|
|
93
|
-
it('should not throw for valid fetcher', () => {
|
|
94
|
-
const validFetcher: PremiumStatusFetcher = {
|
|
95
|
-
isPremium: async () => true,
|
|
96
|
-
};
|
|
97
|
-
expect(() => validateFetcher(validFetcher)).not.toThrow();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('should throw for invalid fetcher', () => {
|
|
101
|
-
expect(() => validateFetcher(null as unknown as PremiumStatusFetcher)).toThrow(TypeError);
|
|
102
|
-
expect(() => validateFetcher({} as unknown as PremiumStatusFetcher)).toThrow(TypeError);
|
|
103
|
-
expect(() => validateFetcher({ isPremium: 'not a function' } as unknown as PremiumStatusFetcher)).toThrow(TypeError);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AI Credit Helpers
|
|
3
|
-
*
|
|
4
|
-
* Common patterns for AI generation apps to handle credits.
|
|
5
|
-
* Provides ready-to-use functions for credit checking and deduction.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* import { createAICreditHelpers } from '@umituz/react-native-subscription';
|
|
9
|
-
*
|
|
10
|
-
* const helpers = createAICreditHelpers({
|
|
11
|
-
* repository,
|
|
12
|
-
* imageGenerationTypes: ['future_image', 'santa_transform'],
|
|
13
|
-
* onCreditDeducted: (userId) => invalidateCache(userId)
|
|
14
|
-
* });
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { CreditsRepository } from "../domains/credits/infrastructure/CreditsRepository";
|
|
18
|
-
import { createCreditChecker } from "./creditChecker";
|
|
19
|
-
|
|
20
|
-
export interface AICreditHelpersConfig {
|
|
21
|
-
/**
|
|
22
|
-
* Credits repository instance
|
|
23
|
-
*/
|
|
24
|
-
repository: CreditsRepository;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Optional map of operation types to credit costs.
|
|
28
|
-
* If an operation isn't in this map, cost defaults to 1.
|
|
29
|
-
* @example { 'high_res_image': 5, 'text_summary': 1 }
|
|
30
|
-
*/
|
|
31
|
-
operationCosts?: Record<string, number>;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Optional callback called after successful credit deduction.
|
|
35
|
-
* Use this to invalidate TanStack Query cache or trigger UI updates.
|
|
36
|
-
*/
|
|
37
|
-
onCreditDeducted?: (userId: string, cost: number) => void;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface AICreditHelpers {
|
|
41
|
-
/**
|
|
42
|
-
* Check if user has credits for a specific generation type
|
|
43
|
-
* @param userId - User ID
|
|
44
|
-
* @param generationType - Type of generation
|
|
45
|
-
* @returns boolean indicating if credits are available
|
|
46
|
-
*/
|
|
47
|
-
checkCreditsForGeneration: (
|
|
48
|
-
userId: string | undefined,
|
|
49
|
-
generationType: string
|
|
50
|
-
) => Promise<boolean>;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Deduct credits after successful generation
|
|
54
|
-
* @param userId - User ID
|
|
55
|
-
* @param generationType - Type of generation that was performed
|
|
56
|
-
*/
|
|
57
|
-
deductCreditsForGeneration: (
|
|
58
|
-
userId: string | undefined,
|
|
59
|
-
generationType: string
|
|
60
|
-
) => Promise<void>;
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Get cost for a generation type
|
|
64
|
-
* @param generationType - Type of generation
|
|
65
|
-
* @returns number of credits
|
|
66
|
-
*/
|
|
67
|
-
getCost: (generationType: string) => number;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Creates AI-specific credit helper functions
|
|
72
|
-
*/
|
|
73
|
-
export function createAICreditHelpers(
|
|
74
|
-
config: AICreditHelpersConfig
|
|
75
|
-
): AICreditHelpers {
|
|
76
|
-
const { repository, operationCosts = {}, onCreditDeducted } = config;
|
|
77
|
-
|
|
78
|
-
// Map generation type to cost
|
|
79
|
-
const getCost = (generationType: string): number => {
|
|
80
|
-
return operationCosts[generationType] ?? 1;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// Create credit checker
|
|
84
|
-
const checker = createCreditChecker({
|
|
85
|
-
repository,
|
|
86
|
-
onCreditDeducted,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Check if credits are available for generation
|
|
90
|
-
const checkCreditsForGeneration = async (
|
|
91
|
-
userId: string | undefined,
|
|
92
|
-
generationType: string
|
|
93
|
-
): Promise<boolean> => {
|
|
94
|
-
const cost = getCost(generationType);
|
|
95
|
-
const result = await checker.checkCreditsAvailable(userId, cost);
|
|
96
|
-
return result.success;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
// Deduct credits after successful generation
|
|
100
|
-
const deductCreditsForGeneration = async (
|
|
101
|
-
userId: string | undefined,
|
|
102
|
-
generationType: string
|
|
103
|
-
): Promise<void> => {
|
|
104
|
-
const cost = getCost(generationType);
|
|
105
|
-
await checker.deductCreditsAfterSuccess(userId, cost);
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
checkCreditsForGeneration,
|
|
110
|
-
deductCreditsForGeneration,
|
|
111
|
-
getCost,
|
|
112
|
-
};
|
|
113
|
-
}
|
package/src/utils/authUtils.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Authentication Utilities
|
|
3
|
-
*
|
|
4
|
-
* Centralized logic for authentication checks
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export function isAuthenticated(
|
|
8
|
-
isGuest: boolean,
|
|
9
|
-
userId: string | null,
|
|
10
|
-
): boolean {
|
|
11
|
-
return !isGuest && userId !== null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function isGuest(
|
|
15
|
-
isGuestFlag: boolean,
|
|
16
|
-
userId: string | null,
|
|
17
|
-
): boolean {
|
|
18
|
-
return isGuestFlag || userId === null;
|
|
19
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credit Checker Utility
|
|
3
|
-
*
|
|
4
|
-
* Validates credit availability before operations.
|
|
5
|
-
* Generic - works with any generation type mapping.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { CreditsRepository } from "../domains/credits/infrastructure/CreditsRepository";
|
|
9
|
-
|
|
10
|
-
export interface CreditCheckResult {
|
|
11
|
-
success: boolean;
|
|
12
|
-
error?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface CreditCheckerConfig {
|
|
16
|
-
repository: CreditsRepository;
|
|
17
|
-
/**
|
|
18
|
-
* Optional callback called after successful credit deduction.
|
|
19
|
-
* Use this to invalidate TanStack Query cache or trigger UI updates.
|
|
20
|
-
* @param userId - The user whose credits were deducted
|
|
21
|
-
* @param cost - The amount of credits deducted
|
|
22
|
-
*/
|
|
23
|
-
onCreditDeducted?: (userId: string, cost: number) => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const createCreditChecker = (config: CreditCheckerConfig) => {
|
|
27
|
-
const { repository, onCreditDeducted } = config;
|
|
28
|
-
|
|
29
|
-
const checkCreditsAvailable = async (
|
|
30
|
-
userId: string | undefined,
|
|
31
|
-
cost: number = 1
|
|
32
|
-
): Promise<CreditCheckResult> => {
|
|
33
|
-
if (!userId) {
|
|
34
|
-
return { success: false, error: "anonymous_user_blocked" };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const hasCreditsAvailable = await repository.hasCredits(userId, cost);
|
|
38
|
-
|
|
39
|
-
if (!hasCreditsAvailable) {
|
|
40
|
-
return {
|
|
41
|
-
success: false,
|
|
42
|
-
error: "credits_exhausted",
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return { success: true };
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const deductCreditsAfterSuccess = async (
|
|
50
|
-
userId: string | undefined,
|
|
51
|
-
cost: number = 1
|
|
52
|
-
): Promise<void> => {
|
|
53
|
-
if (!userId) return;
|
|
54
|
-
|
|
55
|
-
const maxRetries = 3;
|
|
56
|
-
let lastError: Error | null = null;
|
|
57
|
-
|
|
58
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
59
|
-
const result = await repository.deductCredit(userId, cost);
|
|
60
|
-
if (result.success) {
|
|
61
|
-
// Notify subscribers that credits were deducted
|
|
62
|
-
onCreditDeducted?.(userId, cost);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
lastError = new Error(result.error?.message || "Deduction failed");
|
|
66
|
-
await new Promise<void>((r) => setTimeout(() => r(), 500 * (attempt + 1)));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (lastError) {
|
|
70
|
-
throw lastError;
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
checkCreditsAvailable,
|
|
77
|
-
deductCreditsAfterSuccess,
|
|
78
|
-
};
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export type CreditChecker = ReturnType<typeof createCreditChecker>;
|
|
82
|
-
|