@umituz/react-native-subscription 2.27.28 → 2.27.30
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/domain/entities/Credits.ts +2 -0
- package/src/index.ts +2 -0
- package/src/infrastructure/repositories/CreditsRepository.ts +45 -39
- package/src/infrastructure/services/CreditsInitializer.ts +2 -1
- package/src/infrastructure/services/SubscriptionInitializer.ts +31 -4
- package/src/infrastructure/services/TrialService.ts +17 -4
- package/src/presentation/hooks/useAuthAwarePurchase.ts +39 -11
- package/src/presentation/hooks/useCredits.ts +13 -3
- package/src/presentation/hooks/useDeductCredit.ts +20 -9
- package/src/presentation/hooks/useFreeCreditsInit.ts +17 -1
- package/src/presentation/hooks/useSubscriptionStatus.ts +3 -3
- package/src/revenuecat/domain/value-objects/RevenueCatConfig.ts +8 -0
- package/src/revenuecat/infrastructure/handlers/PackageHandler.ts +2 -0
- package/src/revenuecat/infrastructure/services/CustomerInfoListenerManager.ts +46 -1
- package/src/revenuecat/infrastructure/utils/PremiumStatusSyncer.ts +10 -6
- package/src/revenuecat/infrastructure/utils/RenewalDetector.ts +62 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.30",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -87,6 +87,8 @@ export type {
|
|
|
87
87
|
DeductCreditsResult,
|
|
88
88
|
PurchaseSource,
|
|
89
89
|
PurchaseType,
|
|
90
|
+
CreditAllocation,
|
|
91
|
+
PackageAllocationMap,
|
|
90
92
|
} from "./domain/entities/Credits";
|
|
91
93
|
export { DEFAULT_CREDITS_CONFIG } from "./domain/entities/Credits";
|
|
92
94
|
export { InsufficientCreditsError } from "./domain/errors/InsufficientCreditsError";
|
|
@@ -136,6 +136,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
136
136
|
/**
|
|
137
137
|
* Initialize free credits for new users
|
|
138
138
|
* Creates a credits document with freeCredits amount (no subscription)
|
|
139
|
+
* Uses transaction to prevent race condition with premium init
|
|
139
140
|
*/
|
|
140
141
|
async initializeFreeCredits(userId: string): Promise<CreditsResult> {
|
|
141
142
|
const db = getFirestore();
|
|
@@ -148,52 +149,57 @@ export class CreditsRepository extends BaseRepository {
|
|
|
148
149
|
|
|
149
150
|
try {
|
|
150
151
|
const ref = this.getRef(db, userId);
|
|
151
|
-
const snap = await getDoc(ref);
|
|
152
|
-
|
|
153
|
-
// Don't overwrite if document already exists
|
|
154
|
-
if (snap.exists()) {
|
|
155
|
-
if (__DEV__) console.log("[CreditsRepository] Credits document already exists, skipping free credits init");
|
|
156
|
-
const existing = snap.data() as UserCreditsDocumentRead;
|
|
157
|
-
return { success: true, data: CreditsMapper.toEntity(existing) };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Create new document with free credits
|
|
161
|
-
const { setDoc } = await import("firebase/firestore");
|
|
162
|
-
const now = serverTimestamp();
|
|
163
152
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
status: "free" as const,
|
|
168
|
-
|
|
169
|
-
// Free credits - store initial amount for tracking
|
|
170
|
-
credits: freeCredits,
|
|
171
|
-
creditLimit: freeCredits,
|
|
172
|
-
initialFreeCredits: freeCredits,
|
|
173
|
-
isFreeCredits: true,
|
|
174
|
-
|
|
175
|
-
// Dates
|
|
176
|
-
createdAt: now,
|
|
177
|
-
lastUpdatedAt: now,
|
|
178
|
-
};
|
|
153
|
+
// Use transaction to atomically check-and-set
|
|
154
|
+
const result = await runTransaction(db, async (tx: Transaction) => {
|
|
155
|
+
const snap = await tx.get(ref);
|
|
179
156
|
|
|
180
|
-
|
|
157
|
+
// Don't overwrite if document already exists (premium or previous init)
|
|
158
|
+
if (snap.exists()) {
|
|
159
|
+
if (__DEV__) console.log("[CreditsRepository] Credits document already exists, skipping free credits init");
|
|
160
|
+
const existing = snap.data() as UserCreditsDocumentRead;
|
|
161
|
+
return { skipped: true, data: CreditsMapper.toEntity(existing) };
|
|
162
|
+
}
|
|
181
163
|
|
|
182
|
-
|
|
164
|
+
// Create new document with free credits
|
|
165
|
+
const now = serverTimestamp();
|
|
183
166
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
data: {
|
|
167
|
+
const creditsData = {
|
|
168
|
+
// Not premium - just free credits
|
|
187
169
|
isPremium: false,
|
|
188
|
-
status: "free",
|
|
170
|
+
status: "free" as const,
|
|
171
|
+
|
|
172
|
+
// Free credits - store initial amount for tracking
|
|
189
173
|
credits: freeCredits,
|
|
190
174
|
creditLimit: freeCredits,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
175
|
+
initialFreeCredits: freeCredits,
|
|
176
|
+
isFreeCredits: true,
|
|
177
|
+
|
|
178
|
+
// Dates
|
|
179
|
+
createdAt: now,
|
|
180
|
+
lastUpdatedAt: now,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
tx.set(ref, creditsData);
|
|
184
|
+
|
|
185
|
+
if (__DEV__) console.log("[CreditsRepository] Initialized free credits:", { userId: userId.slice(0, 8), credits: freeCredits });
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
skipped: false,
|
|
189
|
+
data: {
|
|
190
|
+
isPremium: false,
|
|
191
|
+
status: "free" as const,
|
|
192
|
+
credits: freeCredits,
|
|
193
|
+
creditLimit: freeCredits,
|
|
194
|
+
purchasedAt: null,
|
|
195
|
+
expirationDate: null,
|
|
196
|
+
lastUpdatedAt: null,
|
|
197
|
+
willRenew: false,
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { success: true, data: result.data };
|
|
197
203
|
} catch (e: any) {
|
|
198
204
|
if (__DEV__) console.error("[CreditsRepository] Free credits init error:", e.message);
|
|
199
205
|
return { success: false, error: { message: e.message, code: "INIT_ERR" } };
|
|
@@ -29,6 +29,7 @@ import { getCreditAllocation } from "../../utils/creditMapper";
|
|
|
29
29
|
|
|
30
30
|
interface InitializationResult {
|
|
31
31
|
credits: number;
|
|
32
|
+
alreadyProcessed?: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface InitializeCreditsMetadata {
|
|
@@ -58,7 +59,7 @@ export async function initializeCreditsTransaction(
|
|
|
58
59
|
let processedPurchases: string[] = existingData?.processedPurchases || [];
|
|
59
60
|
|
|
60
61
|
if (existingData && purchaseId && processedPurchases.includes(purchaseId)) {
|
|
61
|
-
return { credits: existingData.credits, alreadyProcessed: true }
|
|
62
|
+
return { credits: existingData.credits, alreadyProcessed: true };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
if (existingData?.purchasedAt) {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Subscription Initializer
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
declare const __DEV__: boolean;
|
|
6
|
+
|
|
5
7
|
import { Platform } from "react-native";
|
|
6
8
|
import type { CustomerInfo } from "react-native-purchases";
|
|
7
9
|
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
@@ -126,11 +128,36 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
126
128
|
return;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
// If premium became false
|
|
131
|
+
// If premium became false, check if actually expired or just canceled
|
|
130
132
|
if (!isPremium && productId) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
// Check if subscription is truly expired (expiration date in the past)
|
|
134
|
+
const isActuallyExpired = !expiresAt || new Date(expiresAt) < new Date();
|
|
135
|
+
|
|
136
|
+
if (isActuallyExpired) {
|
|
137
|
+
// Subscription truly expired - zero out credits
|
|
138
|
+
await getCreditsRepository().syncExpiredStatus(userId);
|
|
139
|
+
if (__DEV__) {
|
|
140
|
+
console.log('[SubscriptionInitializer] Subscription expired, synced status');
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
// Subscription canceled but not expired - preserve credits until expiration
|
|
144
|
+
if (__DEV__) {
|
|
145
|
+
console.log('[SubscriptionInitializer] Subscription canceled but not expired, preserving credits until:', expiresAt);
|
|
146
|
+
}
|
|
147
|
+
// Update willRenew to false but keep credits
|
|
148
|
+
const revenueCatData: RevenueCatData = {
|
|
149
|
+
expirationDate: expiresAt,
|
|
150
|
+
willRenew: false, // Canceled
|
|
151
|
+
isPremium: true, // Still has access until expiration
|
|
152
|
+
periodType,
|
|
153
|
+
};
|
|
154
|
+
await getCreditsRepository().initializeCredits(
|
|
155
|
+
userId,
|
|
156
|
+
`status_sync_canceled_${Date.now()}`,
|
|
157
|
+
productId,
|
|
158
|
+
"settings" as any,
|
|
159
|
+
revenueCatData
|
|
160
|
+
);
|
|
134
161
|
}
|
|
135
162
|
onCreditsUpdated?.(userId);
|
|
136
163
|
return;
|
|
@@ -28,6 +28,7 @@ export const TRIAL_CONFIG = {
|
|
|
28
28
|
export interface DeviceTrialRecord {
|
|
29
29
|
deviceId: string;
|
|
30
30
|
hasUsedTrial: boolean;
|
|
31
|
+
trialInProgress?: boolean;
|
|
31
32
|
trialStartedAt?: Date;
|
|
32
33
|
trialEndedAt?: Date;
|
|
33
34
|
trialConvertedAt?: Date;
|
|
@@ -80,15 +81,19 @@ export async function checkTrialEligibility(
|
|
|
80
81
|
|
|
81
82
|
const data = trialDoc.data();
|
|
82
83
|
const hasUsedTrial = data?.hasUsedTrial === true;
|
|
84
|
+
const trialInProgress = data?.trialInProgress === true;
|
|
83
85
|
|
|
84
86
|
if (__DEV__) {
|
|
85
87
|
console.log("[TrialService] Trial record found:", {
|
|
86
88
|
deviceId: effectiveDeviceId.slice(0, 8),
|
|
87
89
|
hasUsedTrial,
|
|
90
|
+
trialInProgress,
|
|
88
91
|
});
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
if (
|
|
94
|
+
// Not eligible if trial was already used (converted or ended)
|
|
95
|
+
// OR if trial is currently in progress
|
|
96
|
+
if (hasUsedTrial || trialInProgress) {
|
|
92
97
|
return {
|
|
93
98
|
eligible: false,
|
|
94
99
|
reason: "already_used",
|
|
@@ -129,7 +134,9 @@ export async function recordTrialStart(
|
|
|
129
134
|
trialRef,
|
|
130
135
|
{
|
|
131
136
|
deviceId: effectiveDeviceId,
|
|
132
|
-
hasUsedTrial
|
|
137
|
+
// Don't set hasUsedTrial here - only set when trial ends or converts
|
|
138
|
+
// This allows retry if user cancels before trial period ends
|
|
139
|
+
trialInProgress: true,
|
|
133
140
|
trialStartedAt: serverTimestamp(),
|
|
134
141
|
lastUserId: userId,
|
|
135
142
|
userIds: arrayUnion(userId),
|
|
@@ -183,6 +190,9 @@ export async function recordTrialEnd(
|
|
|
183
190
|
await setDoc(
|
|
184
191
|
trialRef,
|
|
185
192
|
{
|
|
193
|
+
// Mark trial as used when it ends (prevents retry)
|
|
194
|
+
hasUsedTrial: true,
|
|
195
|
+
trialInProgress: false,
|
|
186
196
|
trialEndedAt: serverTimestamp(),
|
|
187
197
|
updatedAt: serverTimestamp(),
|
|
188
198
|
},
|
|
@@ -190,7 +200,7 @@ export async function recordTrialEnd(
|
|
|
190
200
|
);
|
|
191
201
|
|
|
192
202
|
if (__DEV__) {
|
|
193
|
-
console.log("[TrialService] Trial end recorded");
|
|
203
|
+
console.log("[TrialService] Trial end recorded - trial now consumed");
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
return true;
|
|
@@ -221,6 +231,9 @@ export async function recordTrialConversion(
|
|
|
221
231
|
await setDoc(
|
|
222
232
|
trialRef,
|
|
223
233
|
{
|
|
234
|
+
// Mark trial as used after conversion (prevents retry)
|
|
235
|
+
hasUsedTrial: true,
|
|
236
|
+
trialInProgress: false,
|
|
224
237
|
trialConvertedAt: serverTimestamp(),
|
|
225
238
|
updatedAt: serverTimestamp(),
|
|
226
239
|
},
|
|
@@ -228,7 +241,7 @@ export async function recordTrialConversion(
|
|
|
228
241
|
);
|
|
229
242
|
|
|
230
243
|
if (__DEV__) {
|
|
231
|
-
console.log("[TrialService] Trial conversion recorded");
|
|
244
|
+
console.log("[TrialService] Trial conversion recorded - user converted to paid");
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
return true;
|
|
@@ -16,23 +16,40 @@ export interface PurchaseAuthProvider {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
let globalAuthProvider: PurchaseAuthProvider | null = null;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
|
|
20
|
+
interface SavedPurchaseState {
|
|
21
|
+
pkg: PurchasesPackage;
|
|
22
|
+
source: PurchaseSource;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SAVED_PURCHASE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
27
|
+
let savedPurchaseState: SavedPurchaseState | null = null;
|
|
21
28
|
|
|
22
29
|
export const configureAuthProvider = (provider: PurchaseAuthProvider): void => {
|
|
23
30
|
globalAuthProvider = provider;
|
|
24
31
|
};
|
|
25
32
|
|
|
33
|
+
const savePurchase = (pkg: PurchasesPackage, source: PurchaseSource): void => {
|
|
34
|
+
savedPurchaseState = { pkg, source, timestamp: Date.now() };
|
|
35
|
+
};
|
|
36
|
+
|
|
26
37
|
export const getSavedPurchase = (): { pkg: PurchasesPackage; source: PurchaseSource } | null => {
|
|
27
|
-
if (
|
|
28
|
-
return
|
|
38
|
+
if (!savedPurchaseState) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isExpired = Date.now() - savedPurchaseState.timestamp > SAVED_PURCHASE_EXPIRY_MS;
|
|
43
|
+
if (isExpired) {
|
|
44
|
+
savedPurchaseState = null;
|
|
45
|
+
return null;
|
|
29
46
|
}
|
|
30
|
-
|
|
47
|
+
|
|
48
|
+
return { pkg: savedPurchaseState.pkg, source: savedPurchaseState.source };
|
|
31
49
|
};
|
|
32
50
|
|
|
33
51
|
export const clearSavedPurchase = (): void => {
|
|
34
|
-
|
|
35
|
-
savedSource = null;
|
|
52
|
+
savedPurchaseState = null;
|
|
36
53
|
};
|
|
37
54
|
|
|
38
55
|
export interface UseAuthAwarePurchaseParams {
|
|
@@ -69,8 +86,7 @@ export const useAuthAwarePurchase = (
|
|
|
69
86
|
const isAuth = globalAuthProvider.isAuthenticated();
|
|
70
87
|
|
|
71
88
|
if (!isAuth) {
|
|
72
|
-
|
|
73
|
-
savedSource = source || params?.source || "settings";
|
|
89
|
+
savePurchase(pkg, source || params?.source || "settings");
|
|
74
90
|
globalAuthProvider.showAuthModal();
|
|
75
91
|
return false;
|
|
76
92
|
}
|
|
@@ -103,8 +119,20 @@ export const useAuthAwarePurchase = (
|
|
|
103
119
|
console.log("[useAuthAwarePurchase] Executing saved purchase:", saved.pkg.product.identifier);
|
|
104
120
|
}
|
|
105
121
|
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
try {
|
|
123
|
+
const result = await purchasePackage(saved.pkg);
|
|
124
|
+
// Only clear after successful purchase
|
|
125
|
+
if (result) {
|
|
126
|
+
clearSavedPurchase();
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// Don't clear on error - allow retry
|
|
131
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
132
|
+
console.error("[useAuthAwarePurchase] Saved purchase failed:", error);
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
108
136
|
}, [purchasePackage]);
|
|
109
137
|
|
|
110
138
|
return {
|
|
@@ -75,20 +75,30 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
75
75
|
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// If subscription is expired, immediately return 0 credits
|
|
79
|
+
// to prevent any window where expired user could deduct
|
|
78
80
|
if (result.data?.status === "expired") {
|
|
81
|
+
// Sync to Firestore in background
|
|
79
82
|
repository.syncExpiredStatus(userId).catch((syncError) => {
|
|
80
83
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
81
84
|
console.warn("[useCredits] Background sync failed:", syncError);
|
|
82
85
|
}
|
|
83
86
|
});
|
|
87
|
+
|
|
88
|
+
// Return expired data with 0 credits immediately
|
|
89
|
+
return {
|
|
90
|
+
...result.data,
|
|
91
|
+
credits: 0,
|
|
92
|
+
isPremium: false,
|
|
93
|
+
};
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
return result.data || null;
|
|
87
97
|
},
|
|
88
98
|
enabled: queryEnabled,
|
|
89
|
-
staleTime:
|
|
90
|
-
gcTime:
|
|
91
|
-
refetchOnMount:
|
|
99
|
+
staleTime: 30 * 1000, // 30 seconds - data considered fresh
|
|
100
|
+
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache after unmount
|
|
101
|
+
refetchOnMount: "always",
|
|
92
102
|
refetchOnWindowFocus: true,
|
|
93
103
|
refetchOnReconnect: true,
|
|
94
104
|
});
|
|
@@ -39,26 +39,37 @@ export const useDeductCredit = ({
|
|
|
39
39
|
return repository.deductCredit(userId, cost);
|
|
40
40
|
},
|
|
41
41
|
onMutate: async (cost: number) => {
|
|
42
|
-
if (!userId) return;
|
|
42
|
+
if (!userId) return { previousCredits: null, skippedOptimistic: true };
|
|
43
|
+
|
|
43
44
|
await queryClient.cancelQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
44
45
|
const previousCredits = queryClient.getQueryData<UserCredits>(creditsQueryKeys.user(userId));
|
|
46
|
+
|
|
47
|
+
// Skip optimistic update if insufficient credits to prevent showing 0
|
|
48
|
+
if (!previousCredits || previousCredits.credits < cost) {
|
|
49
|
+
return { previousCredits, skippedOptimistic: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
queryClient.setQueryData<UserCredits | null>(creditsQueryKeys.user(userId), (old) => {
|
|
46
53
|
if (!old) return old;
|
|
47
|
-
return {
|
|
48
|
-
...old,
|
|
49
|
-
credits:
|
|
50
|
-
lastUpdatedAt: timezoneService.getNow()
|
|
54
|
+
return {
|
|
55
|
+
...old,
|
|
56
|
+
credits: old.credits - cost,
|
|
57
|
+
lastUpdatedAt: timezoneService.getNow()
|
|
51
58
|
};
|
|
52
59
|
});
|
|
53
|
-
return { previousCredits };
|
|
60
|
+
return { previousCredits, skippedOptimistic: false };
|
|
54
61
|
},
|
|
55
62
|
onError: (_err, _cost, context) => {
|
|
56
|
-
if
|
|
63
|
+
// Always restore previous credits on error if we have them
|
|
64
|
+
if (userId && context?.previousCredits && !context.skippedOptimistic) {
|
|
57
65
|
queryClient.setQueryData(creditsQueryKeys.user(userId), context.previousCredits);
|
|
58
66
|
}
|
|
59
67
|
},
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
onSuccess: () => {
|
|
69
|
+
// Only invalidate on success to get fresh server data
|
|
70
|
+
if (userId) {
|
|
71
|
+
queryClient.invalidateQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
72
|
+
}
|
|
62
73
|
},
|
|
63
74
|
});
|
|
64
75
|
|
|
@@ -63,6 +63,13 @@ async function initializeFreeCreditsForUser(
|
|
|
63
63
|
|
|
64
64
|
const promise = (async () => {
|
|
65
65
|
try {
|
|
66
|
+
if (!isCreditsRepositoryConfigured()) {
|
|
67
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
68
|
+
console.warn("[useFreeCreditsInit] Credits repository not configured");
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
const repository = getCreditsRepository();
|
|
67
74
|
const result = await repository.initializeFreeCredits(userId);
|
|
68
75
|
|
|
@@ -78,6 +85,11 @@ async function initializeFreeCreditsForUser(
|
|
|
78
85
|
}
|
|
79
86
|
return false;
|
|
80
87
|
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
90
|
+
console.error("[useFreeCreditsInit] Unexpected error:", error);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
81
93
|
} finally {
|
|
82
94
|
freeCreditsInitInProgress.delete(userId);
|
|
83
95
|
initPromises.delete(userId);
|
|
@@ -142,7 +154,11 @@ export function useFreeCreditsInit(params: UseFreeCreditsInitParams): UseFreeCre
|
|
|
142
154
|
if (needsInit) {
|
|
143
155
|
// Double-check inside effect to handle race conditions
|
|
144
156
|
if (!freeCreditsInitAttempted.has(userId)) {
|
|
145
|
-
initializeFreeCreditsForUser(userId, stableOnComplete)
|
|
157
|
+
initializeFreeCreditsForUser(userId, stableOnComplete).catch((error) => {
|
|
158
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
159
|
+
console.error("[useFreeCreditsInit] Init failed:", error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
146
162
|
}
|
|
147
163
|
} else if (querySuccess && isAnonymous && !hasCredits && isFreeCreditsEnabled) {
|
|
148
164
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
@@ -37,9 +37,9 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
|
|
|
37
37
|
return SubscriptionManager.checkPremiumStatus();
|
|
38
38
|
},
|
|
39
39
|
enabled: !!userId && SubscriptionManager.isInitializedForUser(userId),
|
|
40
|
-
staleTime:
|
|
41
|
-
gcTime:
|
|
42
|
-
refetchOnMount:
|
|
40
|
+
staleTime: 30 * 1000, // 30 seconds
|
|
41
|
+
gcTime: 5 * 60 * 1000, // 5 minutes
|
|
42
|
+
refetchOnMount: "always",
|
|
43
43
|
refetchOnWindowFocus: true,
|
|
44
44
|
refetchOnReconnect: true,
|
|
45
45
|
});
|
|
@@ -44,6 +44,14 @@ export interface RevenueCatConfig {
|
|
|
44
44
|
newExpirationDate: string,
|
|
45
45
|
customerInfo: CustomerInfo
|
|
46
46
|
) => Promise<void> | void;
|
|
47
|
+
/** Callback when subscription plan changes (upgrade/downgrade) */
|
|
48
|
+
onPlanChanged?: (
|
|
49
|
+
userId: string,
|
|
50
|
+
newProductId: string,
|
|
51
|
+
previousProductId: string,
|
|
52
|
+
isUpgrade: boolean,
|
|
53
|
+
customerInfo: CustomerInfo
|
|
54
|
+
) => Promise<void> | void;
|
|
47
55
|
/** Callback after credits are successfully updated (for cache invalidation) */
|
|
48
56
|
onCreditsUpdated?: (userId: string) => void;
|
|
49
57
|
}
|
|
@@ -7,6 +7,8 @@ import type { PurchasesPackage, CustomerInfo } from "react-native-purchases";
|
|
|
7
7
|
import type { IRevenueCatService } from "../../application/ports/IRevenueCatService";
|
|
8
8
|
import { getPremiumEntitlement } from "../../domain/types/RevenueCatTypes";
|
|
9
9
|
|
|
10
|
+
declare const __DEV__: boolean;
|
|
11
|
+
|
|
10
12
|
export interface PremiumStatus {
|
|
11
13
|
isPremium: boolean;
|
|
12
14
|
expirationDate: Date | null;
|
|
@@ -26,6 +26,13 @@ export class CustomerInfoListenerManager {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
setUserId(userId: string): void {
|
|
29
|
+
// Reset renewal state when user changes to prevent state leak between users
|
|
30
|
+
if (this.currentUserId && this.currentUserId !== userId) {
|
|
31
|
+
this.renewalState = {
|
|
32
|
+
previousExpirationDate: null,
|
|
33
|
+
previousProductId: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
29
36
|
this.currentUserId = userId;
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -51,6 +58,7 @@ export class CustomerInfoListenerManager {
|
|
|
51
58
|
config.entitlementIdentifier
|
|
52
59
|
);
|
|
53
60
|
|
|
61
|
+
// Handle renewal (same product, extended expiration)
|
|
54
62
|
if (renewalResult.isRenewal && config.onRenewalDetected) {
|
|
55
63
|
if (__DEV__) {
|
|
56
64
|
console.log("[CustomerInfoListener] Renewal detected:", {
|
|
@@ -74,9 +82,46 @@ export class CustomerInfoListenerManager {
|
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
// Handle plan change (upgrade/downgrade)
|
|
86
|
+
if (renewalResult.isPlanChange && config.onPlanChanged) {
|
|
87
|
+
if (__DEV__) {
|
|
88
|
+
console.log("[CustomerInfoListener] Plan change detected:", {
|
|
89
|
+
userId: this.currentUserId,
|
|
90
|
+
previousProductId: renewalResult.previousProductId,
|
|
91
|
+
newProductId: renewalResult.productId,
|
|
92
|
+
isUpgrade: renewalResult.isUpgrade,
|
|
93
|
+
isDowngrade: renewalResult.isDowngrade,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await config.onPlanChanged(
|
|
99
|
+
this.currentUserId,
|
|
100
|
+
renewalResult.productId!,
|
|
101
|
+
renewalResult.previousProductId!,
|
|
102
|
+
renewalResult.isUpgrade,
|
|
103
|
+
customerInfo
|
|
104
|
+
);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (__DEV__) {
|
|
107
|
+
console.error("[CustomerInfoListener] Plan change callback failed:", error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
this.renewalState = updateRenewalState(this.renewalState, renewalResult);
|
|
78
113
|
|
|
79
|
-
|
|
114
|
+
// Only sync premium status if NOT a renewal or plan change
|
|
115
|
+
// This prevents double credit initialization
|
|
116
|
+
if (!renewalResult.isRenewal && !renewalResult.isPlanChange) {
|
|
117
|
+
try {
|
|
118
|
+
await syncPremiumStatus(config, this.currentUserId, customerInfo);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (__DEV__) {
|
|
121
|
+
console.error("[CustomerInfoListener] syncPremiumStatus failed:", error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
80
125
|
};
|
|
81
126
|
|
|
82
127
|
Purchases.addCustomerInfoUpdateListener(this.listener);
|
|
@@ -7,6 +7,8 @@ import type { CustomerInfo } from "react-native-purchases";
|
|
|
7
7
|
import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
|
|
8
8
|
import { getPremiumEntitlement } from "../../domain/types/RevenueCatTypes";
|
|
9
9
|
|
|
10
|
+
declare const __DEV__: boolean;
|
|
11
|
+
|
|
10
12
|
export async function syncPremiumStatus(
|
|
11
13
|
config: RevenueCatConfig,
|
|
12
14
|
userId: string,
|
|
@@ -34,13 +36,13 @@ export async function syncPremiumStatus(
|
|
|
34
36
|
} else {
|
|
35
37
|
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined);
|
|
36
38
|
}
|
|
37
|
-
} catch {
|
|
38
|
-
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (__DEV__) {
|
|
41
|
+
console.error('[PremiumStatusSyncer] syncPremiumStatus failed:', error);
|
|
42
|
+
}
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
declare const __DEV__: boolean;
|
|
43
|
-
|
|
44
46
|
export async function notifyPurchaseCompleted(
|
|
45
47
|
config: RevenueCatConfig,
|
|
46
48
|
userId: string,
|
|
@@ -88,7 +90,9 @@ export async function notifyRestoreCompleted(
|
|
|
88
90
|
|
|
89
91
|
try {
|
|
90
92
|
await config.onRestoreCompleted(userId, isPremium, customerInfo);
|
|
91
|
-
} catch {
|
|
92
|
-
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (__DEV__) {
|
|
95
|
+
console.error('[PremiumStatusSyncer] notifyRestoreCompleted failed:', error);
|
|
96
|
+
}
|
|
93
97
|
}
|
|
94
98
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { CustomerInfo } from "react-native-purchases";
|
|
8
|
+
import { detectPackageType, type SubscriptionPackageType } from "../../../utils/packageTypeDetector";
|
|
8
9
|
|
|
9
10
|
export interface RenewalState {
|
|
10
11
|
previousExpirationDate: string | null;
|
|
@@ -13,17 +14,35 @@ export interface RenewalState {
|
|
|
13
14
|
|
|
14
15
|
export interface RenewalDetectionResult {
|
|
15
16
|
isRenewal: boolean;
|
|
17
|
+
isPlanChange: boolean;
|
|
18
|
+
isUpgrade: boolean;
|
|
19
|
+
isDowngrade: boolean;
|
|
16
20
|
productId: string | null;
|
|
21
|
+
previousProductId: string | null;
|
|
17
22
|
newExpirationDate: string | null;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
const PACKAGE_TIER_ORDER: Record<SubscriptionPackageType, number> = {
|
|
26
|
+
weekly: 1,
|
|
27
|
+
monthly: 2,
|
|
28
|
+
yearly: 3,
|
|
29
|
+
unknown: 0,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function getPackageTier(productId: string | null): number {
|
|
33
|
+
if (!productId) return 0;
|
|
34
|
+
const packageType = detectPackageType(productId);
|
|
35
|
+
return PACKAGE_TIER_ORDER[packageType] ?? 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
/**
|
|
21
|
-
* Detects if a subscription renewal occurred
|
|
39
|
+
* Detects if a subscription renewal or plan change occurred
|
|
22
40
|
*
|
|
23
41
|
* Best Practice (RevenueCat):
|
|
24
42
|
* - Track previous expiration date
|
|
25
43
|
* - If new expiration > previous → Renewal detected
|
|
26
|
-
* -
|
|
44
|
+
* - If productId changed → Plan change (upgrade/downgrade)
|
|
45
|
+
* - Reset credits on renewal or plan change (industry standard)
|
|
27
46
|
*
|
|
28
47
|
* @param state Previous state (expiration date, product ID)
|
|
29
48
|
* @param customerInfo Current CustomerInfo from RevenueCat
|
|
@@ -37,34 +56,65 @@ export function detectRenewal(
|
|
|
37
56
|
): RenewalDetectionResult {
|
|
38
57
|
const entitlement = customerInfo.entitlements.active[entitlementId];
|
|
39
58
|
|
|
59
|
+
const baseResult: RenewalDetectionResult = {
|
|
60
|
+
isRenewal: false,
|
|
61
|
+
isPlanChange: false,
|
|
62
|
+
isUpgrade: false,
|
|
63
|
+
isDowngrade: false,
|
|
64
|
+
productId: null,
|
|
65
|
+
previousProductId: state.previousProductId,
|
|
66
|
+
newExpirationDate: null,
|
|
67
|
+
};
|
|
68
|
+
|
|
40
69
|
if (!entitlement) {
|
|
41
|
-
return
|
|
42
|
-
isRenewal: false,
|
|
43
|
-
productId: null,
|
|
44
|
-
newExpirationDate: null,
|
|
45
|
-
};
|
|
70
|
+
return baseResult;
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
const newExpirationDate = entitlement.expirationDate;
|
|
49
74
|
const productId = entitlement.productIdentifier;
|
|
50
75
|
|
|
51
|
-
|
|
76
|
+
// First time seeing this subscription - not a renewal
|
|
77
|
+
if (!state.previousExpirationDate || !state.previousProductId) {
|
|
52
78
|
return {
|
|
53
|
-
|
|
79
|
+
...baseResult,
|
|
54
80
|
productId,
|
|
55
81
|
newExpirationDate,
|
|
56
82
|
};
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
const newExpiration = new Date(newExpirationDate);
|
|
85
|
+
const newExpiration = new Date(newExpirationDate ?? 0);
|
|
60
86
|
const previousExpiration = new Date(state.previousExpirationDate);
|
|
87
|
+
const productChanged = productId !== state.previousProductId;
|
|
88
|
+
const expirationExtended = newExpiration > previousExpiration;
|
|
89
|
+
|
|
90
|
+
// Plan change detection (upgrade/downgrade)
|
|
91
|
+
if (productChanged) {
|
|
92
|
+
const oldTier = getPackageTier(state.previousProductId);
|
|
93
|
+
const newTier = getPackageTier(productId);
|
|
94
|
+
const isUpgrade = newTier > oldTier;
|
|
95
|
+
const isDowngrade = newTier < oldTier;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
isRenewal: false,
|
|
99
|
+
isPlanChange: true,
|
|
100
|
+
isUpgrade,
|
|
101
|
+
isDowngrade,
|
|
102
|
+
productId,
|
|
103
|
+
previousProductId: state.previousProductId,
|
|
104
|
+
newExpirationDate,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
61
107
|
|
|
62
|
-
|
|
63
|
-
|
|
108
|
+
// Same product renewal
|
|
109
|
+
const isRenewal = expirationExtended;
|
|
64
110
|
|
|
65
111
|
return {
|
|
66
112
|
isRenewal,
|
|
113
|
+
isPlanChange: false,
|
|
114
|
+
isUpgrade: false,
|
|
115
|
+
isDowngrade: false,
|
|
67
116
|
productId,
|
|
117
|
+
previousProductId: state.previousProductId,
|
|
68
118
|
newExpirationDate,
|
|
69
119
|
};
|
|
70
120
|
}
|