@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 +1 -1
- package/src/domains/credits/application/RefundCreditsCommand.ts +4 -2
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +8 -2
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +16 -10
- package/src/domains/subscription/application/syncIdGenerators.ts +4 -2
- package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +25 -10
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +8 -6
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +62 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.37.
|
|
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
|
|
43
|
-
const
|
|
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())
|
|
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())
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}_${
|
|
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}_${
|
|
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):
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
try {
|
|
58
|
+
const newRenewalState = await processCustomerInfo(
|
|
59
|
+
customerInfo,
|
|
60
|
+
capturedUserId,
|
|
61
|
+
this.state.renewalState,
|
|
62
|
+
config
|
|
63
|
+
);
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
);
|