@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.22.3",
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) return { success: false, error: { message: "No DB", code: "DB_ERR" } };
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 snap = await getDoc(this.getRef(db, userId));
29
- if (!snap.exists()) return { success: true, data: undefined };
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
- return { success: true, data: CreditsMapper.toEntity(d) };
32
- } catch (e: any) { return { success: false, error: { message: e.message, code: "FETCH_ERR" } }; }
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) return null;
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: enabled && !!userId && isConfigured,
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
+ }