@umituz/react-native-subscription 2.39.9 → 2.39.11
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/CreditLimitCalculator.ts +6 -17
- package/src/domains/credits/core/UserCreditsDocument.ts +1 -1
- package/src/domains/credits/infrastructure/CreditsRepository.ts +3 -3
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +1 -1
- package/src/domains/paywall/components/PaywallFeatures.tsx +1 -1
- package/src/domains/paywall/components/PaywallFooter.tsx +1 -1
- package/src/domains/paywall/components/PaywallScreen.tsx +21 -28
- package/src/domains/paywall/components/PlanCard.tsx +7 -2
- package/src/domains/paywall/components/PlanCard.types.ts +3 -2
- package/src/domains/paywall/utils/paywallLayoutUtils.ts +55 -0
- package/src/domains/revenuecat/core/types/RevenueCatData.ts +1 -1
- package/src/domains/revenuecat/core/types/RevenueCatTypes.ts +2 -2
- package/src/domains/revenuecat/infrastructure/services/RevenueCatInitializer.types.ts +1 -1
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +1 -1
- package/src/domains/subscription/application/SubscriptionInitializerTypes.ts +1 -1
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +5 -22
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +1 -1
- package/src/domains/subscription/application/featureGate/featureGateBusinessRules.ts +27 -10
- package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +42 -41
- package/src/domains/subscription/core/SubscriptionEvents.ts +1 -1
- package/src/domains/subscription/core/SubscriptionStatusHandlers.ts +1 -5
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +4 -6
- package/src/domains/subscription/infrastructure/handlers/PurchaseStatusResolver.ts +2 -2
- package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +1 -1
- package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +1 -1
- package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +1 -1
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +2 -2
- package/src/domains/subscription/infrastructure/managers/initializationHandler.ts +1 -1
- package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +1 -1
- package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +2 -2
- package/src/domains/subscription/infrastructure/services/RestoreHandler.ts +4 -4
- package/src/domains/subscription/infrastructure/services/RevenueCatService.types.ts +1 -1
- package/src/domains/subscription/infrastructure/services/ServiceStateManager.ts +1 -1
- package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +4 -2
- package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +1 -1
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +3 -3
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +2 -1
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +1 -1
- package/src/domains/subscription/infrastructure/services/revenueCatServiceInstance.ts +1 -1
- package/src/domains/subscription/infrastructure/utils/InitializationCache.ts +35 -42
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -3
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCardTypes.ts +1 -1
- package/src/domains/subscription/presentation/components/sections/SubscriptionSection.types.ts +1 -1
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +1 -1
- package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +1 -1
- package/src/domains/subscription/utils/featureGateUtils.ts +37 -0
- package/src/domains/subscription/utils/packageTypeFormatter.ts +1 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +1 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +2 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +2 -1
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +1 -1
- package/src/index.ts +5 -2
- package/src/init/createSubscriptionInitModule.ts +2 -1
- package/src/domains/revenuecat/core/errors/index.ts +0 -3
- package/src/domains/revenuecat/core/types/index.ts +0 -3
- package/src/domains/subscription/application/initializer/index.ts +0 -2
- package/src/domains/subscription/core/types/index.ts +0 -3
- package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +0 -4
- package/src/domains/subscription/infrastructure/utils/renewal/index.ts +0 -3
- package/src/shared/infrastructure/firestore/index.ts +0 -2
- package/src/shared/presentation/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.39.
|
|
3
|
+
"version": "2.39.11",
|
|
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,21 +1,10 @@
|
|
|
1
1
|
import type { CreditsConfig } from "../core/Credits";
|
|
2
|
-
import {
|
|
3
|
-
import { getCreditAllocation } from "../../../utils/creditMapper";
|
|
2
|
+
import { calculateCreditLimit as calculateLimit } from "../utils/creditCalculations";
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Service to calculate credit limits based on product configuration.
|
|
6
|
+
* Uses centralized utility functions for calculations.
|
|
7
|
+
*/
|
|
5
8
|
export function calculateCreditLimit(productId: string | undefined, config: CreditsConfig): number {
|
|
6
|
-
|
|
7
|
-
throw new Error("[CreditLimitCalculator] Cannot calculate credit limit without productId");
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const explicitAmount = config.creditPackageAmounts?.[productId];
|
|
11
|
-
if (explicitAmount !== undefined && explicitAmount !== null) return explicitAmount;
|
|
12
|
-
|
|
13
|
-
const packageType = detectPackageType(productId);
|
|
14
|
-
const dynamicLimit = getCreditAllocation(packageType, config.packageAllocations);
|
|
15
|
-
|
|
16
|
-
if (dynamicLimit === null || dynamicLimit === undefined) {
|
|
17
|
-
throw new Error(`[CreditLimitCalculator] Cannot determine credit limit for productId: ${productId}, packageType: ${packageType}`);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return dynamicLimit;
|
|
9
|
+
return calculateLimit(productId, config);
|
|
21
10
|
}
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
PackageType,
|
|
6
6
|
Platform
|
|
7
7
|
} from "../../subscription/core/SubscriptionConstants";
|
|
8
|
-
import type { Store, OwnershipType } from "../../revenuecat/core/types";
|
|
8
|
+
import type { Store, OwnershipType } from "../../revenuecat/core/types/RevenueCatTypes";
|
|
9
9
|
|
|
10
10
|
export type {
|
|
11
11
|
PurchaseSource,
|
|
@@ -2,14 +2,14 @@ import type { Firestore, DocumentReference } from "@umituz/react-native-firebase
|
|
|
2
2
|
import { BaseRepository } from "@umituz/react-native-firebase";
|
|
3
3
|
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../core/Credits";
|
|
4
4
|
import type { PurchaseSource } from "../core/UserCreditsDocument";
|
|
5
|
-
import type { RevenueCatData } from "../../revenuecat/core/types";
|
|
5
|
+
import type { RevenueCatData } from "../../revenuecat/core/types/RevenueCatData";
|
|
6
6
|
import { deductCreditsOperation } from "../application/DeductCreditsCommand";
|
|
7
7
|
import { refundCreditsOperation } from "../application/RefundCreditsCommand";
|
|
8
8
|
import { PURCHASE_TYPE, type PurchaseType } from "../../subscription/core/SubscriptionConstants";
|
|
9
|
-
import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore";
|
|
9
|
+
import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore/collectionUtils";
|
|
10
10
|
import { fetchCredits, checkHasCredits, documentExists } from "./operations/CreditsFetcher";
|
|
11
11
|
import { syncExpiredStatus, syncPremiumMetadata, createRecoveryCreditsDocument } from "./operations/CreditsWriter";
|
|
12
|
-
import type { SubscriptionMetadata } from "../../subscription/core/types";
|
|
12
|
+
import type { SubscriptionMetadata } from "../../subscription/core/types/SubscriptionMetadata";
|
|
13
13
|
import { initializeCreditsWithRetry } from "./operations/CreditsInitializer";
|
|
14
14
|
import { calculateCreditLimit } from "../application/CreditLimitCalculator";
|
|
15
15
|
|
|
@@ -3,7 +3,7 @@ import type { CreditsConfig, CreditsResult } from "../../core/Credits";
|
|
|
3
3
|
import type { PurchaseSource } from "../../core/UserCreditsDocument";
|
|
4
4
|
import { initializeCreditsTransaction } from "../../application/CreditsInitializer";
|
|
5
5
|
import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
|
|
6
|
-
import type { RevenueCatData } from "../../../revenuecat/core/types";
|
|
6
|
+
import type { RevenueCatData } from "../../../revenuecat/core/types/RevenueCatData";
|
|
7
7
|
import { calculateCreditLimit } from "../../application/CreditLimitCalculator";
|
|
8
8
|
import { PURCHASE_TYPE, type PurchaseType } from "../../../subscription/core/SubscriptionConstants";
|
|
9
9
|
|
|
@@ -3,7 +3,7 @@ import { runTransaction, serverTimestamp } from "@umituz/react-native-firebase";
|
|
|
3
3
|
import { doc, getDoc, setDoc } from "firebase/firestore";
|
|
4
4
|
import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionConstants";
|
|
5
5
|
import { resolveSubscriptionStatus } from "../../../subscription/core/SubscriptionStatus";
|
|
6
|
-
import type { SubscriptionMetadata } from "../../../subscription/core/types";
|
|
6
|
+
import type { SubscriptionMetadata } from "../../../subscription/core/types/SubscriptionMetadata";
|
|
7
7
|
import { toTimestamp } from "../../../../shared/utils/dateConverter";
|
|
8
8
|
import { isPast } from "../../../../utils/dateUtils";
|
|
9
9
|
import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
|
|
@@ -10,7 +10,7 @@ export const PaywallFeatures: React.FC<{ features: SubscriptionFeature[] }> = ({
|
|
|
10
10
|
if (!features.length) return null;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<View style={[styles.
|
|
13
|
+
<View style={[styles.featuresContainer, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
14
14
|
{features.map((feature) => (
|
|
15
15
|
<View key={`${feature.icon}-${feature.text}`} style={styles.featureRow}>
|
|
16
16
|
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primary }]}>
|
|
@@ -25,7 +25,7 @@ export const PaywallFooter: React.FC<PaywallFooterProps> = ({
|
|
|
25
25
|
return (
|
|
26
26
|
<View style={styles.footer}>
|
|
27
27
|
{onRestore && (
|
|
28
|
-
<TouchableOpacity onPress={onRestore} disabled={isProcessing} style={[styles.restoreButton, isProcessing && styles.
|
|
28
|
+
<TouchableOpacity onPress={onRestore} disabled={isProcessing} style={[styles.restoreButton, isProcessing && styles.ctaDisabled]}>
|
|
29
29
|
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
30
30
|
{isProcessing ? translations.processingText : translations.restoreButtonText}
|
|
31
31
|
</AtomicText>
|
|
@@ -17,20 +17,16 @@ import { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-desi
|
|
|
17
17
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
18
18
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
19
19
|
import { Image } from "expo-image";
|
|
20
|
-
import type { PurchasesPackage } from "react-native-purchases";
|
|
21
20
|
import { PlanCard } from "./PlanCard";
|
|
22
21
|
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
23
22
|
import { PaywallFooter } from "./PaywallFooter";
|
|
23
|
+
import { PurchaseLoadingOverlay } from "../../subscription/presentation/components/overlay/PurchaseLoadingOverlay";
|
|
24
24
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
25
25
|
import { PaywallScreenProps } from "./PaywallScreen.types";
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
type PaywallListItem
|
|
29
|
-
|
|
30
|
-
| { type: 'FEATURE_HEADER' }
|
|
31
|
-
| { type: 'FEATURE'; feature: SubscriptionFeature }
|
|
32
|
-
| { type: 'PLAN_HEADER' }
|
|
33
|
-
| { type: 'PLAN'; pkg: PurchasesPackage };
|
|
26
|
+
import {
|
|
27
|
+
calculatePaywallItemLayout,
|
|
28
|
+
type PaywallListItem
|
|
29
|
+
} from "../utils/paywallLayoutUtils";
|
|
34
30
|
|
|
35
31
|
export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) => {
|
|
36
32
|
const {
|
|
@@ -55,7 +51,14 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
55
51
|
const tokens = useAppDesignTokens();
|
|
56
52
|
const insets = useSafeAreaInsets();
|
|
57
53
|
|
|
58
|
-
const {
|
|
54
|
+
const {
|
|
55
|
+
selectedPlanId,
|
|
56
|
+
setSelectedPlanId,
|
|
57
|
+
isProcessing,
|
|
58
|
+
handlePurchase,
|
|
59
|
+
handleRestore,
|
|
60
|
+
resetState
|
|
61
|
+
} = usePaywallActions({
|
|
59
62
|
packages,
|
|
60
63
|
onPurchase,
|
|
61
64
|
onRestore,
|
|
@@ -66,6 +69,13 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
66
69
|
onClose
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
// Reset state when screen is closed to avoid lockups
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
resetState();
|
|
76
|
+
};
|
|
77
|
+
}, [resetState]);
|
|
78
|
+
|
|
69
79
|
// Auto-select first package
|
|
70
80
|
useEffect(() => {
|
|
71
81
|
if (packages.length > 0 && !selectedPlanId) {
|
|
@@ -186,24 +196,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
186
196
|
|
|
187
197
|
// Performance Optimization: getItemLayout for FlatList
|
|
188
198
|
const getItemLayout = useCallback((_data: any, index: number) => {
|
|
189
|
-
|
|
190
|
-
// HEADER: ~300, FEATURE_HEADER: ~60, FEATURE: ~46, PLAN_HEADER: ~60, PLAN: ~80
|
|
191
|
-
let offset = 0;
|
|
192
|
-
for (let i = 0; i < index; i++) {
|
|
193
|
-
const item = flatData[i];
|
|
194
|
-
if (item.type === 'HEADER') offset += 300;
|
|
195
|
-
else if (item.type === 'FEATURE_HEADER' || item.type === 'PLAN_HEADER') offset += 60;
|
|
196
|
-
else if (item.type === 'FEATURE') offset += 46;
|
|
197
|
-
else if (item.type === 'PLAN') offset += 80;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const currentItem = flatData[index];
|
|
201
|
-
let length = 80;
|
|
202
|
-
if (currentItem.type === 'HEADER') length = 300;
|
|
203
|
-
else if (currentItem.type === 'FEATURE_HEADER' || currentItem.type === 'PLAN_HEADER') length = 60;
|
|
204
|
-
else if (currentItem.type === 'FEATURE') length = 46;
|
|
205
|
-
|
|
206
|
-
return { length, offset, index };
|
|
199
|
+
return calculatePaywallItemLayout(flatData, index);
|
|
207
200
|
}, [flatData]);
|
|
208
201
|
|
|
209
202
|
const keyExtractor = useCallback((item: PaywallListItem, index: number) => {
|
|
@@ -6,13 +6,18 @@ import { formatPriceWithPeriod } from '../../../utils/priceUtils';
|
|
|
6
6
|
import { PlanCardProps } from "./PlanCard.types";
|
|
7
7
|
|
|
8
8
|
export const PlanCard: React.FC<PlanCardProps> = React.memo(
|
|
9
|
-
({ pkg, isSelected, onSelect, badge, creditAmount, creditsLabel }) => {
|
|
9
|
+
({ pkg, isSelected, onSelect, badge, creditAmount, creditsLabel, disabled }) => {
|
|
10
10
|
const tokens = useAppDesignTokens();
|
|
11
11
|
const title = pkg.product.title;
|
|
12
12
|
const price = formatPriceWithPeriod(pkg.product.price, pkg.product.currencyCode, pkg.identifier);
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<TouchableOpacity
|
|
15
|
+
<TouchableOpacity
|
|
16
|
+
onPress={onSelect}
|
|
17
|
+
disabled={disabled}
|
|
18
|
+
activeOpacity={0.7}
|
|
19
|
+
style={[styles.touchable, disabled && { opacity: 0.8 }]}
|
|
20
|
+
>
|
|
16
21
|
<View style={[styles.container, {
|
|
17
22
|
backgroundColor: tokens.colors.surface,
|
|
18
23
|
borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
import type { SubscriptionFeature } from "../entities/types";
|
|
3
|
+
|
|
4
|
+
export type PaywallListItem =
|
|
5
|
+
| { type: 'HEADER' }
|
|
6
|
+
| { type: 'FEATURE_HEADER' }
|
|
7
|
+
| { type: 'FEATURE'; feature: SubscriptionFeature }
|
|
8
|
+
| { type: 'PLAN_HEADER' }
|
|
9
|
+
| { type: 'PLAN'; pkg: PurchasesPackage };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Constants for estimated layout heights
|
|
13
|
+
*/
|
|
14
|
+
export const LAYOUT_CONSTANTS = {
|
|
15
|
+
HEADER_HEIGHT: 300,
|
|
16
|
+
SECTION_HEADER_HEIGHT: 60,
|
|
17
|
+
FEATURE_ITEM_HEIGHT: 46,
|
|
18
|
+
PLAN_ITEM_HEIGHT: 80,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculates the offset and length for FlatList items to optimize scrolling performance.
|
|
23
|
+
*/
|
|
24
|
+
export function calculatePaywallItemLayout(data: PaywallListItem[] | null | undefined, index: number) {
|
|
25
|
+
if (!data) return { length: 0, offset: 0, index };
|
|
26
|
+
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (let i = 0; i < index; i++) {
|
|
29
|
+
const item = data[i];
|
|
30
|
+
offset += getItemHeight(item);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const length = getItemHeight(data[index]);
|
|
34
|
+
|
|
35
|
+
return { length, offset, index };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the estimated height of a single paywall list item based on its type.
|
|
40
|
+
*/
|
|
41
|
+
function getItemHeight(item: PaywallListItem): number {
|
|
42
|
+
switch (item.type) {
|
|
43
|
+
case 'HEADER':
|
|
44
|
+
return LAYOUT_CONSTANTS.HEADER_HEIGHT;
|
|
45
|
+
case 'FEATURE_HEADER':
|
|
46
|
+
case 'PLAN_HEADER':
|
|
47
|
+
return LAYOUT_CONSTANTS.SECTION_HEADER_HEIGHT;
|
|
48
|
+
case 'FEATURE':
|
|
49
|
+
return LAYOUT_CONSTANTS.FEATURE_ITEM_HEIGHT;
|
|
50
|
+
case 'PLAN':
|
|
51
|
+
return LAYOUT_CONSTANTS.PLAN_ITEM_HEIGHT;
|
|
52
|
+
default:
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SubscriptionMetadata } from "../../../subscription/core/types";
|
|
1
|
+
import type { SubscriptionMetadata } from "../../../subscription/core/types/SubscriptionMetadata";
|
|
2
2
|
import type { PackageType } from "./RevenueCatTypes";
|
|
3
3
|
|
|
4
4
|
export interface RevenueCatData extends Omit<SubscriptionMetadata, 'willRenew' | 'productId'> {
|
|
@@ -88,9 +88,9 @@ export function isInvalidCredentialsError(error: unknown): boolean {
|
|
|
88
88
|
return code === "INVALID_CREDENTIALS_ERROR" || code === "9";
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
export function getRawErrorMessage(error: unknown
|
|
91
|
+
export function getRawErrorMessage(error: unknown): string {
|
|
92
92
|
if (error instanceof Error) {
|
|
93
93
|
return error.message;
|
|
94
94
|
}
|
|
95
|
-
return
|
|
95
|
+
return "Unknown error";
|
|
96
96
|
}
|
|
@@ -3,7 +3,7 @@ import type { InitializeResult } from "../../../../shared/application/ports/IRev
|
|
|
3
3
|
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
4
4
|
import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
|
|
5
5
|
import { UserSwitchMutex } from "./UserSwitchMutex";
|
|
6
|
-
import { getPremiumEntitlement } from "../../core/types";
|
|
6
|
+
import { getPremiumEntitlement } from "../../core/types/RevenueCatTypes";
|
|
7
7
|
import { ANONYMOUS_CACHE_KEY, type PeriodType } from "../../../subscription/core/SubscriptionConstants";
|
|
8
8
|
|
|
9
9
|
declare const __DEV__: boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CreditsConfig } from "../../credits/core/Credits";
|
|
2
2
|
import type { UserCreditsDocumentRead } from "../../credits/core/UserCreditsDocument";
|
|
3
3
|
import type { PurchaseSource, PurchaseType } from "../core/SubscriptionConstants";
|
|
4
|
-
import type { SubscriptionMetadata } from "../core/types";
|
|
4
|
+
import type { SubscriptionMetadata } from "../core/types/SubscriptionMetadata";
|
|
5
5
|
|
|
6
6
|
export interface FirebaseAuthLike {
|
|
7
7
|
currentUser: { uid: string; isAnonymous: boolean } | null;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Purchases from "react-native-purchases";
|
|
2
1
|
import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
3
2
|
import type { PremiumStatusChangedEvent, PurchaseCompletedEvent, RenewalDetectedEvent } from "../core/SubscriptionEvents";
|
|
4
3
|
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
@@ -80,14 +79,6 @@ export class SubscriptionSyncProcessor {
|
|
|
80
79
|
|
|
81
80
|
// ─── Internal Processing ──────────────────────────────────────────
|
|
82
81
|
|
|
83
|
-
private async getRevenueCatAppUserId(): Promise<string | null> {
|
|
84
|
-
try {
|
|
85
|
-
return await Purchases.getAppUserID();
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
82
|
private async getCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
|
|
92
83
|
const trimmed = revenueCatUserId?.trim();
|
|
93
84
|
if (trimmed && trimmed.length > 0 && trimmed !== 'undefined' && trimmed !== 'null') {
|
|
@@ -108,8 +99,8 @@ export class SubscriptionSyncProcessor {
|
|
|
108
99
|
try {
|
|
109
100
|
const revenueCatData = extractRevenueCatData(event.customerInfo, this.entitlementId);
|
|
110
101
|
revenueCatData.packageType = event.packageType ?? null;
|
|
111
|
-
|
|
112
|
-
revenueCatData.revenueCatUserId =
|
|
102
|
+
// Use the event.userId instead of polling the SDK to avoid race conditions during rapid user switching
|
|
103
|
+
revenueCatData.revenueCatUserId = event.userId;
|
|
113
104
|
const purchaseId = generatePurchaseId(revenueCatData.storeTransactionId, event.productId);
|
|
114
105
|
|
|
115
106
|
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
@@ -138,8 +129,8 @@ export class SubscriptionSyncProcessor {
|
|
|
138
129
|
try {
|
|
139
130
|
const revenueCatData = extractRevenueCatData(event.customerInfo, this.entitlementId);
|
|
140
131
|
revenueCatData.expirationDate = event.newExpirationDate ?? revenueCatData.expirationDate;
|
|
141
|
-
|
|
142
|
-
revenueCatData.revenueCatUserId =
|
|
132
|
+
// Use the event.userId instead of polling the SDK to avoid race conditions during rapid user switching
|
|
133
|
+
revenueCatData.revenueCatUserId = event.userId;
|
|
143
134
|
const purchaseId = generateRenewalId(revenueCatData.storeTransactionId, event.productId, event.newExpirationDate);
|
|
144
135
|
|
|
145
136
|
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
@@ -164,9 +155,6 @@ export class SubscriptionSyncProcessor {
|
|
|
164
155
|
}
|
|
165
156
|
|
|
166
157
|
private async processStatusChange(event: PremiumStatusChangedEvent): Promise<void> {
|
|
167
|
-
// If a purchase is in progress, skip metadata sync (purchase handler does it)
|
|
168
|
-
// but still allow recovery to run — the purchase handler's credit initialization
|
|
169
|
-
// might have failed, and this is the safety net.
|
|
170
158
|
if (this.purchaseInProgress) {
|
|
171
159
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
172
160
|
console.log("[SubscriptionSyncProcessor] Purchase in progress - running recovery only");
|
|
@@ -186,9 +174,6 @@ export class SubscriptionSyncProcessor {
|
|
|
186
174
|
}
|
|
187
175
|
|
|
188
176
|
if (!event.isPremium && !event.productId) {
|
|
189
|
-
// No entitlement and no productId — could be:
|
|
190
|
-
// 1. Free user who never purchased (no credits doc) → skip
|
|
191
|
-
// 2. Previously premium user whose entitlement was removed → expire
|
|
192
177
|
const hasDoc = await getCreditsRepository().creditsDocumentExists(creditsUserId);
|
|
193
178
|
if (hasDoc) {
|
|
194
179
|
await this.expireSubscription(creditsUserId);
|
|
@@ -203,7 +188,7 @@ export class SubscriptionSyncProcessor {
|
|
|
203
188
|
await this.syncPremiumStatus(creditsUserId, event);
|
|
204
189
|
}
|
|
205
190
|
|
|
206
|
-
// ─── Credit Document Operations
|
|
191
|
+
// ─── Credit Document Operations ───
|
|
207
192
|
|
|
208
193
|
private async expireSubscription(userId: string): Promise<void> {
|
|
209
194
|
await getCreditsRepository().syncExpiredStatus(userId);
|
|
@@ -213,8 +198,6 @@ export class SubscriptionSyncProcessor {
|
|
|
213
198
|
private async syncPremiumStatus(userId: string, event: PremiumStatusChangedEvent): Promise<void> {
|
|
214
199
|
const repo = getCreditsRepository();
|
|
215
200
|
|
|
216
|
-
// Recovery: if premium user has no credits document, create one.
|
|
217
|
-
// Handles edge cases like test store, reinstalls, or failed purchase initialization.
|
|
218
201
|
if (event.isPremium) {
|
|
219
202
|
const created = await repo.ensurePremiumCreditsExist(
|
|
220
203
|
userId,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
-
import type { RevenueCatData } from "../../revenuecat/core/types";
|
|
2
|
+
import type { RevenueCatData } from "../../revenuecat/core/types/RevenueCatData";
|
|
3
3
|
import { PERIOD_TYPE, type PeriodType } from "../core/SubscriptionConstants";
|
|
4
4
|
|
|
5
5
|
function validatePeriodType(periodType: string | undefined): PeriodType | null {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canExecuteAuthAction as canAuth,
|
|
3
|
+
canExecutePurchaseAction as canPurchase
|
|
4
|
+
} from "../../utils/featureGateUtils";
|
|
5
|
+
|
|
1
6
|
export const DEFAULT_REQUIRED_CREDITS = 1;
|
|
2
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Business rule for executing auth-related actions.
|
|
10
|
+
*/
|
|
3
11
|
export function canExecuteAuthAction(
|
|
4
12
|
isWaitingForAuthCredits: boolean,
|
|
5
13
|
isCreditsLoaded: boolean,
|
|
@@ -8,12 +16,19 @@ export function canExecuteAuthAction(
|
|
|
8
16
|
creditBalance: number,
|
|
9
17
|
requiredCredits: number
|
|
10
18
|
): boolean {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
return canAuth(
|
|
20
|
+
isWaitingForAuthCredits,
|
|
21
|
+
isCreditsLoaded,
|
|
22
|
+
hasPendingAction,
|
|
23
|
+
hasSubscription,
|
|
24
|
+
creditBalance,
|
|
25
|
+
requiredCredits
|
|
26
|
+
);
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Business rule for executing purchase-related actions.
|
|
31
|
+
*/
|
|
17
32
|
export function canExecutePurchaseAction(
|
|
18
33
|
isWaitingForPurchase: boolean,
|
|
19
34
|
creditBalance: number,
|
|
@@ -22,10 +37,12 @@ export function canExecutePurchaseAction(
|
|
|
22
37
|
prevHasSubscription: boolean,
|
|
23
38
|
hasPendingAction: boolean
|
|
24
39
|
): boolean {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
return canPurchase(
|
|
41
|
+
isWaitingForPurchase,
|
|
42
|
+
creditBalance,
|
|
43
|
+
prevBalance,
|
|
44
|
+
hasSubscription,
|
|
45
|
+
prevHasSubscription,
|
|
46
|
+
hasPendingAction
|
|
47
|
+
);
|
|
31
48
|
}
|
|
@@ -2,16 +2,18 @@ import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionM
|
|
|
2
2
|
import { getCurrentUserId, setupAuthStateListener } from "../SubscriptionAuthListener";
|
|
3
3
|
import type { SubscriptionInitConfig } from "../SubscriptionInitializerTypes";
|
|
4
4
|
|
|
5
|
-
const AUTH_STATE_DEBOUNCE_MS = 500;
|
|
5
|
+
const AUTH_STATE_DEBOUNCE_MS = 500;
|
|
6
6
|
const MAX_RETRY_ATTEMPTS = 3;
|
|
7
7
|
const RETRY_DELAY_MS = 2000;
|
|
8
8
|
|
|
9
9
|
export async function startBackgroundInitialization(config: SubscriptionInitConfig): Promise<() => void> {
|
|
10
10
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
11
11
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
// Track the ID of the current initialization sequence to abort stale retries/state updates
|
|
14
|
+
let currentSequenceId = 0;
|
|
13
15
|
let lastInitSucceeded = false;
|
|
14
|
-
let
|
|
16
|
+
let lastUserId: string | undefined = undefined;
|
|
15
17
|
|
|
16
18
|
const initializeInBackground = async (revenueCatUserId?: string): Promise<void> => {
|
|
17
19
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -20,20 +22,23 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
20
22
|
await SubscriptionManager.initialize(revenueCatUserId);
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
const attemptInitWithRetry = async (revenueCatUserId
|
|
24
|
-
// Abort if
|
|
25
|
-
if (
|
|
25
|
+
const attemptInitWithRetry = async (revenueCatUserId: string | undefined, attempt: number, sequenceId: number): Promise<void> => {
|
|
26
|
+
// Abort if this is no longer the active sequence (e.g., user changed)
|
|
27
|
+
if (sequenceId !== currentSequenceId) {
|
|
26
28
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
27
|
-
console.log('[BackgroundInitializer] Aborting retry -
|
|
29
|
+
console.log('[BackgroundInitializer] Aborting retry - sequence changed');
|
|
28
30
|
}
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
try {
|
|
33
35
|
await initializeInBackground(revenueCatUserId);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if (sequenceId === currentSequenceId) {
|
|
37
|
+
lastInitSucceeded = true;
|
|
38
|
+
}
|
|
36
39
|
} catch (error) {
|
|
40
|
+
if (sequenceId !== currentSequenceId) return;
|
|
41
|
+
|
|
37
42
|
lastInitSucceeded = false;
|
|
38
43
|
console.error('[BackgroundInitializer] Initialization failed:', {
|
|
39
44
|
userId: revenueCatUserId,
|
|
@@ -47,12 +52,11 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
47
52
|
console.log('[BackgroundInitializer] Scheduling retry', { attempt: attempt + 2 });
|
|
48
53
|
}
|
|
49
54
|
retryTimer = setTimeout(() => {
|
|
50
|
-
|
|
55
|
+
// Fire and forget promise, but safe because of sequenceId check
|
|
56
|
+
attemptInitWithRetry(revenueCatUserId, attempt + 1, sequenceId).catch(err => {
|
|
57
|
+
console.error('[BackgroundInitializer] Retry failed unhandled:', err);
|
|
58
|
+
});
|
|
51
59
|
}, RETRY_DELAY_MS * (attempt + 1));
|
|
52
|
-
} else {
|
|
53
|
-
// After all retries failed, set lastUserId so we don't block
|
|
54
|
-
// but mark as failed so next auth change can retry
|
|
55
|
-
lastUserId = revenueCatUserId;
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
};
|
|
@@ -66,7 +70,7 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
66
70
|
retryTimer = null;
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
if (lastUserId === revenueCatUserId &&
|
|
73
|
+
if (lastUserId === revenueCatUserId && lastInitSucceeded) {
|
|
70
74
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
71
75
|
console.log('[BackgroundInitializer] UserId unchanged and init succeeded, skipping');
|
|
72
76
|
}
|
|
@@ -74,8 +78,10 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
debounceTimer = setTimeout(async () => {
|
|
77
|
-
//
|
|
78
|
-
|
|
81
|
+
// Start a new sequence
|
|
82
|
+
currentSequenceId++;
|
|
83
|
+
const sequenceId = currentSequenceId;
|
|
84
|
+
|
|
79
85
|
if (!revenueCatUserId && !lastUserId) {
|
|
80
86
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
81
87
|
console.log('[BackgroundInitializer] No user and no previous user, waiting for auth');
|
|
@@ -87,18 +93,19 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
87
93
|
console.log('[BackgroundInitializer] Auth state listener triggered, reinitializing with userId:', revenueCatUserId || '(undefined - anonymous)');
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
// Important: Always reset on user change, not just on logout.
|
|
97
|
+
// This ensures previous user's cached state is cleared before init.
|
|
98
|
+
if (lastUserId !== revenueCatUserId) {
|
|
99
|
+
await SubscriptionManager.reset();
|
|
100
|
+
lastInitSucceeded = false;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
103
|
+
lastUserId = revenueCatUserId;
|
|
104
|
+
|
|
105
|
+
// Start the retry chain
|
|
106
|
+
attemptInitWithRetry(revenueCatUserId, 0, sequenceId).catch(err => {
|
|
107
|
+
console.error('[BackgroundInitializer] Init sequence failed unhandled:', err);
|
|
108
|
+
});
|
|
102
109
|
}, AUTH_STATE_DEBOUNCE_MS);
|
|
103
110
|
};
|
|
104
111
|
|
|
@@ -114,12 +121,11 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
114
121
|
console.log('[BackgroundInitializer] Initial RevenueCat userId:', initialRevenueCatUserId || '(undefined - anonymous)');
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
// Initialize RevenueCat for all users (including anonymous).
|
|
118
|
-
// Anonymous users get their Firebase UID passed to RevenueCat so they can make purchases.
|
|
119
|
-
// Credits are stored at users/{uid}/credits/balance regardless of auth status.
|
|
120
124
|
if (initialRevenueCatUserId) {
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
currentSequenceId++;
|
|
126
|
+
attemptInitWithRetry(initialRevenueCatUserId, 0, currentSequenceId).catch(err => {
|
|
127
|
+
console.error('[BackgroundInitializer] Initial sequence failed unhandled:', err);
|
|
128
|
+
});
|
|
123
129
|
} else if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
124
130
|
console.log('[BackgroundInitializer] No user available yet, waiting for auth state');
|
|
125
131
|
}
|
|
@@ -127,14 +133,9 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
|
|
|
127
133
|
const unsubscribe = setupAuthStateListener(() => auth, debouncedInitialize);
|
|
128
134
|
|
|
129
135
|
return () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
clearTimeout(retryTimer);
|
|
135
|
-
}
|
|
136
|
-
if (unsubscribe) {
|
|
137
|
-
unsubscribe();
|
|
138
|
-
}
|
|
136
|
+
currentSequenceId++; // Invalidate any running sequences
|
|
137
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
138
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
139
|
+
if (unsubscribe) unsubscribe();
|
|
139
140
|
};
|
|
140
141
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
2
|
import type { PurchaseSource } from "./SubscriptionConstants";
|
|
3
|
-
import type { SubscriptionMetadata } from "./types";
|
|
3
|
+
import type { SubscriptionMetadata } from "./types/SubscriptionMetadata";
|
|
4
4
|
import type { PackageType } from "../../revenuecat/core/types/RevenueCatTypes";
|
|
5
5
|
|
|
6
6
|
export interface PurchaseCompletedEvent {
|