@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.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/application/CreditLimitCalculator.ts +6 -17
  3. package/src/domains/credits/core/UserCreditsDocument.ts +1 -1
  4. package/src/domains/credits/infrastructure/CreditsRepository.ts +3 -3
  5. package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
  6. package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +1 -1
  7. package/src/domains/paywall/components/PaywallFeatures.tsx +1 -1
  8. package/src/domains/paywall/components/PaywallFooter.tsx +1 -1
  9. package/src/domains/paywall/components/PaywallScreen.tsx +21 -28
  10. package/src/domains/paywall/components/PlanCard.tsx +7 -2
  11. package/src/domains/paywall/components/PlanCard.types.ts +3 -2
  12. package/src/domains/paywall/utils/paywallLayoutUtils.ts +55 -0
  13. package/src/domains/revenuecat/core/types/RevenueCatData.ts +1 -1
  14. package/src/domains/revenuecat/core/types/RevenueCatTypes.ts +2 -2
  15. package/src/domains/revenuecat/infrastructure/services/RevenueCatInitializer.types.ts +1 -1
  16. package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +1 -1
  17. package/src/domains/subscription/application/SubscriptionInitializerTypes.ts +1 -1
  18. package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +5 -22
  19. package/src/domains/subscription/application/SubscriptionSyncUtils.ts +1 -1
  20. package/src/domains/subscription/application/featureGate/featureGateBusinessRules.ts +27 -10
  21. package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +42 -41
  22. package/src/domains/subscription/core/SubscriptionEvents.ts +1 -1
  23. package/src/domains/subscription/core/SubscriptionStatusHandlers.ts +1 -5
  24. package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +4 -6
  25. package/src/domains/subscription/infrastructure/handlers/PurchaseStatusResolver.ts +2 -2
  26. package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +1 -1
  27. package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +1 -1
  28. package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +1 -1
  29. package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +2 -2
  30. package/src/domains/subscription/infrastructure/managers/initializationHandler.ts +1 -1
  31. package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +1 -1
  32. package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +2 -2
  33. package/src/domains/subscription/infrastructure/services/RestoreHandler.ts +4 -4
  34. package/src/domains/subscription/infrastructure/services/RevenueCatService.types.ts +1 -1
  35. package/src/domains/subscription/infrastructure/services/ServiceStateManager.ts +1 -1
  36. package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +4 -2
  37. package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +1 -1
  38. package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +3 -3
  39. package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +2 -1
  40. package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +1 -1
  41. package/src/domains/subscription/infrastructure/services/revenueCatServiceInstance.ts +1 -1
  42. package/src/domains/subscription/infrastructure/utils/InitializationCache.ts +35 -42
  43. package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -3
  44. package/src/domains/subscription/presentation/components/details/PremiumDetailsCardTypes.ts +1 -1
  45. package/src/domains/subscription/presentation/components/sections/SubscriptionSection.types.ts +1 -1
  46. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
  47. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +1 -1
  48. package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +1 -1
  49. package/src/domains/subscription/utils/featureGateUtils.ts +37 -0
  50. package/src/domains/subscription/utils/packageTypeFormatter.ts +1 -1
  51. package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +1 -1
  52. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +2 -1
  53. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +2 -1
  54. package/src/domains/wallet/presentation/screens/WalletScreen.tsx +1 -1
  55. package/src/index.ts +5 -2
  56. package/src/init/createSubscriptionInitModule.ts +2 -1
  57. package/src/domains/revenuecat/core/errors/index.ts +0 -3
  58. package/src/domains/revenuecat/core/types/index.ts +0 -3
  59. package/src/domains/subscription/application/initializer/index.ts +0 -2
  60. package/src/domains/subscription/core/types/index.ts +0 -3
  61. package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +0 -4
  62. package/src/domains/subscription/infrastructure/utils/renewal/index.ts +0 -3
  63. package/src/shared/infrastructure/firestore/index.ts +0 -2
  64. 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.9",
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 { detectPackageType } from "../../../utils/packageTypeDetector";
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
- if (!productId) {
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.features, { backgroundColor: tokens.colors.surfaceSecondary }]}>
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.restoreButtonDisabled]}>
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 type { SubscriptionFeature } from "../entities/types";
27
-
28
- type PaywallListItem =
29
- | { type: 'HEADER' }
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 { selectedPlanId, setSelectedPlanId, isProcessing, handlePurchase, handleRestore } = usePaywallActions({
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
- // Estimated heights for different item types
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 onPress={onSelect} activeOpacity={0.7} style={styles.touchable}>
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,
@@ -5,6 +5,7 @@ export interface PlanCardProps {
5
5
  isSelected: boolean;
6
6
  onSelect: () => void;
7
7
  badge?: string;
8
- creditAmount?: number;
8
+ creditAmount?: number | null;
9
9
  creditsLabel?: string;
10
- }
10
+ disabled?: boolean;
11
+ }
@@ -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, fallback: string): string {
91
+ export function getRawErrorMessage(error: unknown): string {
92
92
  if (error instanceof Error) {
93
93
  return error.message;
94
94
  }
95
- return fallback;
95
+ return "Unknown error";
96
96
  }
@@ -1,4 +1,4 @@
1
- import type { RevenueCatConfig } from "../../core/types";
1
+ import type { RevenueCatConfig } from "../../core/types/RevenueCatConfig";
2
2
 
3
3
  export interface InitializerDeps {
4
4
  config: RevenueCatConfig;
@@ -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
- const revenueCatAppUserId = await this.getRevenueCatAppUserId();
112
- revenueCatData.revenueCatUserId = revenueCatAppUserId ?? event.userId;
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
- const revenueCatAppUserId = await this.getRevenueCatAppUserId();
142
- revenueCatData.revenueCatUserId = revenueCatAppUserId ?? event.userId;
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 (replaces statusChangeHandlers) ───
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
- if (!isWaitingForAuthCredits || !isCreditsLoaded || !hasPendingAction) {
12
- return false;
13
- }
14
- return hasSubscription || creditBalance >= requiredCredits;
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
- if (!isWaitingForPurchase || !hasPendingAction) {
26
- return false;
27
- }
28
- const creditsIncreased = creditBalance > prevBalance;
29
- const subscriptionAcquired = hasSubscription && !prevHasSubscription;
30
- return creditsIncreased || subscriptionAcquired;
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; // Wait 500ms before processing auth state changes
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
- let lastUserId: string | undefined = undefined;
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 isInitializing = false; // true while attemptInitWithRetry is awaited
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?: string, attempt = 0): Promise<void> => {
24
- // Abort if user changed since retry was scheduled
25
- if (attempt > 0 && lastUserId !== revenueCatUserId) {
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 - user changed');
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
- lastUserId = revenueCatUserId;
35
- lastInitSucceeded = true;
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
- void attemptInitWithRetry(revenueCatUserId, attempt + 1);
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 && (lastInitSucceeded || isInitializing)) {
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
- // Don't initialize when there's no user and no previous user.
78
- // This is the initial signed-out state before auth resolves, not a logout.
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
- // Reset subscription state on logout to prevent stale cache
91
- if (!revenueCatUserId && lastUserId) {
92
- await SubscriptionManager.reset();
93
- lastInitSucceeded = false;
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
- isInitializing = true;
97
- try {
98
- await attemptInitWithRetry(revenueCatUserId);
99
- } finally {
100
- isInitializing = false;
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
- await initializeInBackground(initialRevenueCatUserId);
122
- lastInitSucceeded = true;
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
- if (debounceTimer) {
131
- clearTimeout(debounceTimer);
132
- }
133
- if (retryTimer) {
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 {