@umituz/react-native-subscription 2.27.29 → 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/infrastructure/repositories/CreditsRepository.ts +45 -39
- package/src/infrastructure/services/SubscriptionInitializer.ts +31 -4
- package/src/infrastructure/services/TrialService.ts +17 -4
- package/src/presentation/hooks/useAuthAwarePurchase.ts +14 -2
- package/src/presentation/hooks/useCredits.ts +10 -0
- package/src/presentation/hooks/useDeductCredit.ts +20 -9
- package/src/revenuecat/domain/value-objects/RevenueCatConfig.ts +8 -0
- package/src/revenuecat/infrastructure/services/CustomerInfoListenerManager.ts +44 -5
- 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",
|
|
@@ -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" } };
|
|
@@ -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;
|
|
@@ -119,8 +119,20 @@ export const useAuthAwarePurchase = (
|
|
|
119
119
|
console.log("[useAuthAwarePurchase] Executing saved purchase:", saved.pkg.product.identifier);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
124
136
|
}, [purchasePackage]);
|
|
125
137
|
|
|
126
138
|
return {
|
|
@@ -75,12 +75,22 @@ 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;
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -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,13 +82,44 @@ 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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
123
|
}
|
|
85
124
|
}
|
|
86
125
|
};
|
|
@@ -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
|
}
|