@umituz/react-native-subscription 2.37.73 → 2.37.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.37.73",
3
+ "version": "2.37.75",
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",
@@ -39,8 +39,10 @@ export async function refundCreditsOperation(
39
39
  throw new Error(CREDIT_ERROR_CODES.NO_CREDITS);
40
40
  }
41
41
 
42
- const current = docSnap.data().credits as number;
43
- const updated = current + amount;
42
+ const data = docSnap.data();
43
+ const current = data.credits as number;
44
+ const creditLimit = (data.creditLimit as number) ?? Infinity;
45
+ const updated = Math.min(current + amount, creditLimit);
44
46
 
45
47
  tx.update(creditsRef, {
46
48
  credits: updated,
@@ -9,7 +9,10 @@ import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
9
9
 
10
10
  export async function syncExpiredStatus(ref: DocumentReference): Promise<void> {
11
11
  const doc = await getDoc(ref);
12
- if (!doc.exists()) return;
12
+ if (!doc.exists()) {
13
+ console.warn("[CreditsWriter] syncExpiredStatus: credits document does not exist, skipping.", ref.path);
14
+ return;
15
+ }
13
16
 
14
17
  await setDoc(ref, {
15
18
  isPremium: false,
@@ -36,7 +39,10 @@ export async function syncPremiumMetadata(
36
39
  metadata: PremiumMetadata
37
40
  ): Promise<void> {
38
41
  const doc = await getDoc(ref);
39
- if (!doc.exists()) return;
42
+ if (!doc.exists()) {
43
+ console.warn("[CreditsWriter] syncPremiumMetadata: credits document does not exist, skipping.", ref.path);
44
+ return;
45
+ }
40
46
 
41
47
  const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
42
48
  const status = resolveSubscriptionStatus({
@@ -25,15 +25,19 @@ export class SubscriptionSyncProcessor {
25
25
  }
26
26
  }
27
27
 
28
- private async getCreditsUserId(revenueCatUserId: string): Promise<string> {
29
- if (!revenueCatUserId || revenueCatUserId.trim().length === 0) {
30
- const anonymousId = await this.getAnonymousUserId();
31
- if (!anonymousId || anonymousId.trim().length === 0) {
32
- throw new Error("[SubscriptionSyncProcessor] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
33
- }
34
- return anonymousId;
28
+ private async getCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
29
+ const trimmed = revenueCatUserId?.trim();
30
+ if (trimmed && trimmed.length > 0) {
31
+ return trimmed;
32
+ }
33
+
34
+ console.warn("[SubscriptionSyncProcessor] revenueCatUserId is empty/null, falling back to anonymousUserId");
35
+ const anonymousId = await this.getAnonymousUserId();
36
+ const trimmedAnonymous = anonymousId?.trim();
37
+ if (!trimmedAnonymous || trimmedAnonymous.length === 0) {
38
+ throw new Error("[SubscriptionSyncProcessor] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
35
39
  }
36
- return revenueCatUserId;
40
+ return trimmedAnonymous;
37
41
  }
38
42
 
39
43
  async processPurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource, packageType?: PackageType | null) {
@@ -41,7 +45,8 @@ export class SubscriptionSyncProcessor {
41
45
  try {
42
46
  const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
43
47
  revenueCatData.packageType = packageType ?? null;
44
- revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
48
+ const revenueCatAppUserId = await this.getRevenueCatAppUserId();
49
+ revenueCatData.revenueCatUserId = revenueCatAppUserId ?? userId;
45
50
  const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, productId);
46
51
 
47
52
  const creditsUserId = await this.getCreditsUserId(userId);
@@ -66,7 +71,8 @@ export class SubscriptionSyncProcessor {
66
71
  try {
67
72
  const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
68
73
  revenueCatData.expirationDate = newExpirationDate ?? revenueCatData.expirationDate;
69
- revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
74
+ const revenueCatAppUserId = await this.getRevenueCatAppUserId();
75
+ revenueCatData.revenueCatUserId = revenueCatAppUserId ?? userId;
70
76
  const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, productId, newExpirationDate);
71
77
 
72
78
  const creditsUserId = await this.getCreditsUserId(userId);
@@ -1,11 +1,13 @@
1
+ const uniqueSuffix = (): string => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2
+
1
3
  export const generatePurchaseId = (originalTransactionId: string | null, productId: string): string => {
2
4
  return originalTransactionId
3
5
  ? `purchase_${originalTransactionId}`
4
- : `purchase_${productId}_${Date.now()}`;
6
+ : `purchase_${productId}_${uniqueSuffix()}`;
5
7
  };
6
8
 
7
9
  export const generateRenewalId = (originalTransactionId: string | null, productId: string, expirationDate: string): string => {
8
10
  return originalTransactionId
9
11
  ? `renewal_${originalTransactionId}_${expirationDate}`
10
- : `renewal_${productId}_${Date.now()}`;
12
+ : `renewal_${productId}_${uniqueSuffix()}`;
11
13
  };
@@ -26,9 +26,20 @@ export class CustomerInfoListenerManager {
26
26
  this.state.resetRenewalState();
27
27
  }
28
28
 
29
- setupListener(config: RevenueCatConfig): void {
29
+ setupListener(config: RevenueCatConfig): boolean {
30
30
  this.removeListener();
31
31
 
32
+ try {
33
+ this._createAndAttachListener(config);
34
+ return true;
35
+ } catch (error) {
36
+ console.error("[CustomerInfoListenerManager] Failed to setup listener:", error);
37
+ this.state.currentUserId = null;
38
+ return false;
39
+ }
40
+ }
41
+
42
+ private _createAndAttachListener(config: RevenueCatConfig): void {
32
43
  this.state.listener = async (customerInfo: CustomerInfo) => {
33
44
  if (typeof __DEV__ !== "undefined" && __DEV__) {
34
45
  console.log("[CustomerInfoListener] 🔔 LISTENER TRIGGERED!", {
@@ -43,17 +54,21 @@ export class CustomerInfoListenerManager {
43
54
  return;
44
55
  }
45
56
 
46
- const newRenewalState = await processCustomerInfo(
47
- customerInfo,
48
- capturedUserId,
49
- this.state.renewalState,
50
- config
51
- );
57
+ try {
58
+ const newRenewalState = await processCustomerInfo(
59
+ customerInfo,
60
+ capturedUserId,
61
+ this.state.renewalState,
62
+ config
63
+ );
52
64
 
53
- if (this.state.currentUserId === capturedUserId) {
54
- this.state.renewalState = newRenewalState;
65
+ if (this.state.currentUserId === capturedUserId) {
66
+ this.state.renewalState = newRenewalState;
67
+ }
68
+ // else: User switched during async operation, discard stale renewal state
69
+ } catch (error) {
70
+ console.error("[CustomerInfoListener] processCustomerInfo failed:", error);
55
71
  }
56
- // else: User switched during async operation, discard stale renewal state
57
72
  };
58
73
 
59
74
  Purchases.addCustomerInfoUpdateListener(this.state.listener);
@@ -13,15 +13,16 @@ async function executeConsumablePurchase(
13
13
  ): Promise<PurchaseResult> {
14
14
  const savedPurchase = getSavedPurchase();
15
15
  const source = savedPurchase?.source;
16
- if (savedPurchase) {
17
- clearSavedPurchase();
18
- }
19
16
 
20
17
  try {
21
18
  await notifyPurchaseCompleted(config, userId, productId, customerInfo, source, packageType);
22
19
  } catch (syncError) {
23
20
  // Non-fatal: RevenueCat purchase succeeded, credits sync can be recovered on next session
24
21
  console.error('[PurchaseExecutor] Post-purchase sync failed (purchase was successful):', syncError);
22
+ } finally {
23
+ if (savedPurchase) {
24
+ clearSavedPurchase();
25
+ }
25
26
  }
26
27
 
27
28
  return {
@@ -44,9 +45,6 @@ async function executeSubscriptionPurchase(
44
45
  const isPremium = !!customerInfo.entitlements.active[entitlementIdentifier];
45
46
  const savedPurchase = getSavedPurchase();
46
47
  const source = savedPurchase?.source;
47
- if (savedPurchase) {
48
- clearSavedPurchase();
49
- }
50
48
 
51
49
  if (typeof __DEV__ !== "undefined" && __DEV__) {
52
50
  console.log("[PurchaseExecutor] executeSubscriptionPurchase:", {
@@ -65,6 +63,10 @@ async function executeSubscriptionPurchase(
65
63
  } catch (syncError) {
66
64
  // Non-fatal: RevenueCat purchase succeeded, credits sync can be recovered on next session
67
65
  console.error('[PurchaseExecutor] Post-purchase sync failed (purchase was successful):', syncError);
66
+ } finally {
67
+ if (savedPurchase) {
68
+ clearSavedPurchase();
69
+ }
68
70
  }
69
71
 
70
72
  return {
@@ -1,6 +1,6 @@
1
- import React, { useMemo } from "react";
2
- import { StyleSheet, View, Pressable } from "react-native";
3
- import { AtomicIcon } from "@umituz/react-native-design-system/atoms";
1
+ import React, { useMemo, useState, useCallback } from "react";
2
+ import { StyleSheet, View, Pressable, Alert } from "react-native";
3
+ import { AtomicIcon, AtomicText } from "@umituz/react-native-design-system/atoms";
4
4
  import { NavigationHeader } from "@umituz/react-native-design-system/molecules";
5
5
  import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
6
6
  import { ScreenLayout } from "../../../../shared/presentation";
@@ -9,6 +9,8 @@ import { CreditsList } from "./components/CreditsList";
9
9
  import { UpgradePrompt } from "./components/UpgradePrompt";
10
10
  import { SubscriptionDetailScreenProps } from "./SubscriptionDetailScreen.types";
11
11
 
12
+ const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__;
13
+
12
14
  export const SubscriptionDetailScreen: React.FC<SubscriptionDetailScreenProps> = ({ config }) => {
13
15
  const tokens = useAppDesignTokens();
14
16
  const { showHeader, showCredits, showUpgradePrompt, showExpirationDate } = config.display;
@@ -81,7 +83,64 @@ export const SubscriptionDetailScreen: React.FC<SubscriptionDetailScreenProps> =
81
83
  onUpgrade={config.upgradePrompt.onUpgrade ?? (() => {})}
82
84
  />
83
85
  )}
86
+ {IS_DEV && <DevTestPanel />}
84
87
  </View>
85
88
  </ScreenLayout>
86
89
  );
87
90
  };
91
+
92
+ /* ─── DEV TEST PANEL ─── Only rendered in __DEV__ ─── */
93
+
94
+ const DevTestPanel: React.FC = () => {
95
+ const [loading, setLoading] = useState<string | null>(null);
96
+
97
+ const run = useCallback(async (label: string, fn: () => Promise<void>) => {
98
+ if (loading) return;
99
+ setLoading(label);
100
+ try {
101
+ await fn();
102
+ Alert.alert("DEV", `${label} OK`);
103
+ } catch (e) {
104
+ Alert.alert("DEV Error", e instanceof Error ? e.message : String(e));
105
+ } finally {
106
+ setLoading(null);
107
+ }
108
+ }, [loading]);
109
+
110
+ const handleCancel = useCallback(() => run("Cancel", async () => {
111
+ const { useAuthStore, selectUserId } = require("@umituz/react-native-auth");
112
+ const { handleExpiredSubscription } = require("../../application/statusChangeHandlers");
113
+ const userId = selectUserId(useAuthStore.getState());
114
+ if (!userId) throw new Error("No userId found");
115
+ await handleExpiredSubscription(userId);
116
+ }), [run]);
117
+
118
+ const handleRestore = useCallback(() => run("Restore", async () => {
119
+ const Purchases = require("react-native-purchases").default;
120
+ await Purchases.restorePurchases();
121
+ }), [run]);
122
+
123
+ return (
124
+ <View style={{ marginTop: 16, padding: 16, borderRadius: 12, backgroundColor: "#1a1a2e", borderWidth: 1, borderColor: "#e94560" }}>
125
+ <AtomicText type="labelLarge" style={{ color: "#e94560", marginBottom: 12, textAlign: "center" }}>
126
+ DEV TEST PANEL
127
+ </AtomicText>
128
+ <View style={{ gap: 8 }}>
129
+ <DevButton label="Cancel" color="#e94560" loading={loading} onPress={handleCancel} />
130
+ <DevButton label="Restore" color="#0f3460" loading={loading} onPress={handleRestore} />
131
+ </View>
132
+ </View>
133
+ );
134
+ };
135
+
136
+ const DevButton: React.FC<{ label: string; color: string; loading: string | null; onPress: () => void }> = ({ label, color, loading, onPress }) => (
137
+ <Pressable
138
+ onPress={onPress}
139
+ disabled={!!loading}
140
+ style={{ backgroundColor: loading === label ? "#555" : color, padding: 12, borderRadius: 8, alignItems: "center" }}
141
+ >
142
+ <AtomicText type="labelMedium" style={{ color: "#fff" }}>
143
+ {loading === label ? `${label}...` : label}
144
+ </AtomicText>
145
+ </Pressable>
146
+ );