@umituz/react-native-subscription 2.22.3 → 2.22.5
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 +20 -5
- package/src/infrastructure/services/SubscriptionInitializer.ts +23 -1
- package/src/presentation/hooks/useCredits.ts +21 -2
- package/src/revenuecat/domain/value-objects/RevenueCatConfig.ts +7 -0
- package/src/revenuecat/infrastructure/services/CustomerInfoListenerManager.ts +47 -1
- package/src/revenuecat/infrastructure/utils/RenewalDetector.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.5",
|
|
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",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Credits Repository
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
declare const __DEV__: boolean;
|
|
6
|
+
|
|
5
7
|
import { doc, getDoc, runTransaction, serverTimestamp, type Firestore, type Transaction } from "firebase/firestore";
|
|
6
8
|
import { BaseRepository, getFirestore } from "@umituz/react-native-firebase";
|
|
7
9
|
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../../domain/entities/Credits";
|
|
@@ -23,13 +25,26 @@ export class CreditsRepository extends BaseRepository {
|
|
|
23
25
|
|
|
24
26
|
async getCredits(userId: string): Promise<CreditsResult> {
|
|
25
27
|
const db = getFirestore();
|
|
26
|
-
if (!db)
|
|
28
|
+
if (!db) {
|
|
29
|
+
if (__DEV__) console.log("[CreditsRepository] No Firestore instance");
|
|
30
|
+
return { success: false, error: { message: "No DB", code: "DB_ERR" } };
|
|
31
|
+
}
|
|
27
32
|
try {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
33
|
+
const ref = this.getRef(db, userId);
|
|
34
|
+
if (__DEV__) console.log("[CreditsRepository] Fetching credits:", { userId: userId.slice(0, 8), path: ref.path });
|
|
35
|
+
const snap = await getDoc(ref);
|
|
36
|
+
if (!snap.exists()) {
|
|
37
|
+
if (__DEV__) console.log("[CreditsRepository] No credits document found");
|
|
38
|
+
return { success: true, data: undefined };
|
|
39
|
+
}
|
|
30
40
|
const d = snap.data() as UserCreditsDocumentRead;
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
const entity = CreditsMapper.toEntity(d);
|
|
42
|
+
if (__DEV__) console.log("[CreditsRepository] Credits fetched:", { credits: entity.credits, limit: entity.creditLimit });
|
|
43
|
+
return { success: true, data: entity };
|
|
44
|
+
} catch (e: any) {
|
|
45
|
+
if (__DEV__) console.error("[CreditsRepository] Fetch error:", e.message);
|
|
46
|
+
return { success: false, error: { message: e.message, code: "FETCH_ERR" } };
|
|
47
|
+
}
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
async initializeCredits(
|
|
@@ -61,8 +61,30 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
|
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
const onRenewal = async (userId: string, productId: string, _newExpirationDate: string, _customerInfo: unknown) => {
|
|
65
|
+
if (__DEV__) {
|
|
66
|
+
console.log('[SubscriptionInitializer] onRenewal called:', { userId, productId });
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const result = await getCreditsRepository().initializeCredits(
|
|
70
|
+
userId,
|
|
71
|
+
`renewal_${productId}_${Date.now()}`,
|
|
72
|
+
productId,
|
|
73
|
+
"renewal" as any
|
|
74
|
+
);
|
|
75
|
+
if (__DEV__) {
|
|
76
|
+
console.log('[SubscriptionInitializer] Credits reset on renewal:', result);
|
|
77
|
+
}
|
|
78
|
+
onCreditsUpdated?.(userId);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (__DEV__) {
|
|
81
|
+
console.error('[SubscriptionInitializer] Renewal credits init failed:', error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
64
86
|
SubscriptionManager.configure({
|
|
65
|
-
config: { apiKey: key, testStoreKey, entitlementIdentifier: entitlementId, consumableProductIdentifiers: [creditPackages?.identifierPattern || "credit"], onPurchaseCompleted: onPurchase, onCreditsUpdated },
|
|
87
|
+
config: { apiKey: key, testStoreKey, entitlementIdentifier: entitlementId, consumableProductIdentifiers: [creditPackages?.identifierPattern || "credit"], onPurchaseCompleted: onPurchase, onRenewalDetected: onRenewal, onCreditsUpdated },
|
|
66
88
|
apiKey: key, getAnonymousUserId
|
|
67
89
|
});
|
|
68
90
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Generic and reusable - uses config from module-level provider.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
declare const __DEV__: boolean;
|
|
9
|
+
|
|
8
10
|
import { useQuery } from "@tanstack/react-query";
|
|
9
11
|
import { useCallback, useMemo } from "react";
|
|
10
12
|
import type { UserCredits } from "../../domain/entities/Credits";
|
|
@@ -61,18 +63,35 @@ export const useCredits = ({
|
|
|
61
63
|
const staleTime = cache?.staleTime ?? DEFAULT_STALE_TIME;
|
|
62
64
|
const gcTime = cache?.gcTime ?? DEFAULT_GC_TIME;
|
|
63
65
|
|
|
66
|
+
const queryEnabled = enabled && !!userId && isConfigured;
|
|
67
|
+
|
|
68
|
+
if (__DEV__) {
|
|
69
|
+
console.log("[useCredits] Query state:", {
|
|
70
|
+
userId: userId?.slice(0, 8),
|
|
71
|
+
enabled,
|
|
72
|
+
isConfigured,
|
|
73
|
+
queryEnabled,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
const { data, isLoading, error, refetch } = useQuery({
|
|
65
78
|
queryKey: creditsQueryKeys.user(userId ?? ""),
|
|
66
79
|
queryFn: async () => {
|
|
67
|
-
if (!userId || !isConfigured)
|
|
80
|
+
if (!userId || !isConfigured) {
|
|
81
|
+
if (__DEV__) console.log("[useCredits] Query skipped:", { hasUserId: !!userId, isConfigured });
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (__DEV__) console.log("[useCredits] Executing queryFn for userId:", userId.slice(0, 8));
|
|
68
85
|
const repository = getCreditsRepository();
|
|
69
86
|
const result = await repository.getCredits(userId);
|
|
70
87
|
if (!result.success) {
|
|
88
|
+
if (__DEV__) console.error("[useCredits] Query failed:", result.error?.message);
|
|
71
89
|
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
72
90
|
}
|
|
91
|
+
if (__DEV__) console.log("[useCredits] Query success:", { hasData: !!result.data, credits: result.data?.credits });
|
|
73
92
|
return result.data || null;
|
|
74
93
|
},
|
|
75
|
-
enabled:
|
|
94
|
+
enabled: queryEnabled,
|
|
76
95
|
staleTime,
|
|
77
96
|
gcTime,
|
|
78
97
|
refetchOnMount: true, // Refetch when component mounts
|
|
@@ -34,6 +34,13 @@ export interface RevenueCatConfig {
|
|
|
34
34
|
isPremium: boolean,
|
|
35
35
|
customerInfo: CustomerInfo
|
|
36
36
|
) => Promise<void> | void;
|
|
37
|
+
/** Callback when subscription renewal is detected */
|
|
38
|
+
onRenewalDetected?: (
|
|
39
|
+
userId: string,
|
|
40
|
+
productId: string,
|
|
41
|
+
newExpirationDate: string,
|
|
42
|
+
customerInfo: CustomerInfo
|
|
43
|
+
) => Promise<void> | void;
|
|
37
44
|
/** Callback after credits are successfully updated (for cache invalidation) */
|
|
38
45
|
onCreditsUpdated?: (userId: string) => void;
|
|
39
46
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Customer Info Listener Manager
|
|
3
|
-
* Handles RevenueCat customer info update listeners
|
|
3
|
+
* Handles RevenueCat customer info update listeners with renewal detection
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import Purchases, {
|
|
@@ -9,10 +9,21 @@ import Purchases, {
|
|
|
9
9
|
} from "react-native-purchases";
|
|
10
10
|
import type { RevenueCatConfig } from "../../domain/value-objects/RevenueCatConfig";
|
|
11
11
|
import { syncPremiumStatus } from "../utils/PremiumStatusSyncer";
|
|
12
|
+
import {
|
|
13
|
+
detectRenewal,
|
|
14
|
+
updateRenewalState,
|
|
15
|
+
type RenewalState,
|
|
16
|
+
} from "../utils/RenewalDetector";
|
|
17
|
+
|
|
18
|
+
declare const __DEV__: boolean;
|
|
12
19
|
|
|
13
20
|
export class CustomerInfoListenerManager {
|
|
14
21
|
private listener: CustomerInfoUpdateListener | null = null;
|
|
15
22
|
private currentUserId: string | null = null;
|
|
23
|
+
private renewalState: RenewalState = {
|
|
24
|
+
previousExpirationDate: null,
|
|
25
|
+
previousProductId: null,
|
|
26
|
+
};
|
|
16
27
|
|
|
17
28
|
setUserId(userId: string): void {
|
|
18
29
|
this.currentUserId = userId;
|
|
@@ -20,6 +31,10 @@ export class CustomerInfoListenerManager {
|
|
|
20
31
|
|
|
21
32
|
clearUserId(): void {
|
|
22
33
|
this.currentUserId = null;
|
|
34
|
+
this.renewalState = {
|
|
35
|
+
previousExpirationDate: null,
|
|
36
|
+
previousProductId: null,
|
|
37
|
+
};
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
setupListener(config: RevenueCatConfig): void {
|
|
@@ -30,6 +45,37 @@ export class CustomerInfoListenerManager {
|
|
|
30
45
|
return;
|
|
31
46
|
}
|
|
32
47
|
|
|
48
|
+
const renewalResult = detectRenewal(
|
|
49
|
+
this.renewalState,
|
|
50
|
+
customerInfo,
|
|
51
|
+
config.entitlementIdentifier
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (renewalResult.isRenewal && config.onRenewalDetected) {
|
|
55
|
+
if (__DEV__) {
|
|
56
|
+
console.log("[CustomerInfoListener] Renewal detected:", {
|
|
57
|
+
userId: this.currentUserId,
|
|
58
|
+
productId: renewalResult.productId,
|
|
59
|
+
newExpiration: renewalResult.newExpirationDate,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await config.onRenewalDetected(
|
|
65
|
+
this.currentUserId,
|
|
66
|
+
renewalResult.productId!,
|
|
67
|
+
renewalResult.newExpirationDate!,
|
|
68
|
+
customerInfo
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (__DEV__) {
|
|
72
|
+
console.error("[CustomerInfoListener] Renewal callback failed:", error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.renewalState = updateRenewalState(this.renewalState, renewalResult);
|
|
78
|
+
|
|
33
79
|
syncPremiumStatus(config, this.currentUserId, customerInfo);
|
|
34
80
|
};
|
|
35
81
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renewal Detector
|
|
3
|
+
* Detects subscription renewals by tracking expiration date changes
|
|
4
|
+
* Best Practice: Compare expiration dates to detect renewal events
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CustomerInfo } from "react-native-purchases";
|
|
8
|
+
|
|
9
|
+
export interface RenewalState {
|
|
10
|
+
previousExpirationDate: string | null;
|
|
11
|
+
previousProductId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RenewalDetectionResult {
|
|
15
|
+
isRenewal: boolean;
|
|
16
|
+
productId: string | null;
|
|
17
|
+
newExpirationDate: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detects if a subscription renewal occurred
|
|
22
|
+
*
|
|
23
|
+
* Best Practice (RevenueCat):
|
|
24
|
+
* - Track previous expiration date
|
|
25
|
+
* - If new expiration > previous → Renewal detected
|
|
26
|
+
* - Reset credits on renewal (industry standard)
|
|
27
|
+
*
|
|
28
|
+
* @param state Previous state (expiration date, product ID)
|
|
29
|
+
* @param customerInfo Current CustomerInfo from RevenueCat
|
|
30
|
+
* @param entitlementId Entitlement identifier to check
|
|
31
|
+
* @returns Renewal detection result
|
|
32
|
+
*/
|
|
33
|
+
export function detectRenewal(
|
|
34
|
+
state: RenewalState,
|
|
35
|
+
customerInfo: CustomerInfo,
|
|
36
|
+
entitlementId: string
|
|
37
|
+
): RenewalDetectionResult {
|
|
38
|
+
const entitlement = customerInfo.entitlements.active[entitlementId];
|
|
39
|
+
|
|
40
|
+
if (!entitlement) {
|
|
41
|
+
return {
|
|
42
|
+
isRenewal: false,
|
|
43
|
+
productId: null,
|
|
44
|
+
newExpirationDate: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const newExpirationDate = entitlement.expirationDate;
|
|
49
|
+
const productId = entitlement.productIdentifier;
|
|
50
|
+
|
|
51
|
+
if (!newExpirationDate || !state.previousExpirationDate) {
|
|
52
|
+
return {
|
|
53
|
+
isRenewal: false,
|
|
54
|
+
productId,
|
|
55
|
+
newExpirationDate,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const newExpiration = new Date(newExpirationDate);
|
|
60
|
+
const previousExpiration = new Date(state.previousExpirationDate);
|
|
61
|
+
|
|
62
|
+
const isRenewal = newExpiration > previousExpiration &&
|
|
63
|
+
productId === state.previousProductId;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
isRenewal,
|
|
67
|
+
productId,
|
|
68
|
+
newExpirationDate,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Updates renewal state after detection
|
|
74
|
+
*/
|
|
75
|
+
export function updateRenewalState(
|
|
76
|
+
_state: RenewalState,
|
|
77
|
+
result: RenewalDetectionResult
|
|
78
|
+
): RenewalState {
|
|
79
|
+
return {
|
|
80
|
+
previousExpirationDate: result.newExpirationDate,
|
|
81
|
+
previousProductId: result.productId,
|
|
82
|
+
};
|
|
83
|
+
}
|