@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.27.28",
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",
@@ -101,4 +101,6 @@ export interface DeductCreditsResult {
101
101
  export const DEFAULT_CREDITS_CONFIG: CreditsConfig = {
102
102
  collectionName: "user_credits",
103
103
  creditLimit: 100,
104
+ enableFreeCredits: false,
105
+ freeCredits: 0,
104
106
  };
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
- const creditsData = {
165
- // Not premium - just free credits
166
- isPremium: false,
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
- await setDoc(ref, creditsData);
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
- if (__DEV__) console.log("[CreditsRepository] Initialized free credits:", { userId: userId.slice(0, 8), credits: freeCredits });
164
+ // Create new document with free credits
165
+ const now = serverTimestamp();
183
166
 
184
- return {
185
- success: true,
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
- purchasedAt: null,
192
- expirationDate: null,
193
- lastUpdatedAt: null,
194
- willRenew: false,
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 } as InitializationResult & { alreadyProcessed: boolean };
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 (subscription expired/canceled), sync expired status only
131
+ // If premium became false, check if actually expired or just canceled
130
132
  if (!isPremium && productId) {
131
- await getCreditsRepository().syncExpiredStatus(userId);
132
- if (__DEV__) {
133
- console.log('[SubscriptionInitializer] Subscription expired, synced status');
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 (hasUsedTrial) {
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: true,
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
- let savedPackage: PurchasesPackage | null = null;
20
- let savedSource: PurchaseSource | null = null;
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 (savedPackage && savedSource) {
28
- return { pkg: savedPackage, source: savedSource };
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
- return null;
47
+
48
+ return { pkg: savedPurchaseState.pkg, source: savedPurchaseState.source };
31
49
  };
32
50
 
33
51
  export const clearSavedPurchase = (): void => {
34
- savedPackage = null;
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
- savedPackage = pkg;
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
- clearSavedPurchase();
107
- return purchasePackage(saved.pkg);
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: 0,
90
- gcTime: 0,
91
- refetchOnMount: true,
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: Math.max(0, old.credits - cost),
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 (userId && context?.previousCredits) {
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
- onSettled: () => {
61
- if (userId) queryClient.invalidateQueries({ queryKey: creditsQueryKeys.user(userId) });
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: 0,
41
- gcTime: 0,
42
- refetchOnMount: true,
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
- syncPremiumStatus(config, this.currentUserId, customerInfo);
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
- // Silent error handling
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
- // Silent error handling
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
- * - Reset credits on renewal (industry standard)
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
- if (!newExpirationDate || !state.previousExpirationDate) {
76
+ // First time seeing this subscription - not a renewal
77
+ if (!state.previousExpirationDate || !state.previousProductId) {
52
78
  return {
53
- isRenewal: false,
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
- const isRenewal = newExpiration > previousExpiration &&
63
- productId === state.previousProductId;
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
  }