@umituz/react-native-subscription 2.22.8 → 2.22.10

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.8",
3
+ "version": "2.22.10",
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",
@@ -1,20 +1,22 @@
1
1
  import type { UserCredits, SubscriptionStatus } from "../../domain/entities/Credits";
2
2
  import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
3
3
 
4
- /** Maps Firestore document to domain entity */
4
+ /** Maps Firestore document to domain entity with expiration validation */
5
5
  export class CreditsMapper {
6
6
  static toEntity(doc: UserCreditsDocumentRead): UserCredits {
7
- // Determine status from document or derive from isPremium/expirationDate
8
- const status = doc.status ?? CreditsMapper.deriveStatus(doc);
7
+ const expirationDate = doc.expirationDate?.toDate?.() ?? null;
8
+
9
+ // Validate isPremium against expirationDate (real-time check)
10
+ const { isPremium, status } = CreditsMapper.validateSubscription(doc, expirationDate);
9
11
 
10
12
  return {
11
- // Core subscription
12
- isPremium: doc.isPremium ?? false,
13
+ // Core subscription (validated)
14
+ isPremium,
13
15
  status,
14
16
 
15
17
  // Dates
16
18
  purchasedAt: doc.purchasedAt?.toDate?.() ?? null,
17
- expirationDate: doc.expirationDate?.toDate?.() ?? null,
19
+ expirationDate,
18
20
  lastUpdatedAt: doc.lastUpdatedAt?.toDate?.() ?? null,
19
21
 
20
22
  // RevenueCat details
@@ -35,14 +37,33 @@ export class CreditsMapper {
35
37
  };
36
38
  }
37
39
 
38
- /** Derive status from isPremium and expirationDate for backward compatibility */
39
- private static deriveStatus(doc: UserCreditsDocumentRead): SubscriptionStatus {
40
- if (!doc.isPremium && !doc.expirationDate) return "free";
41
- if (doc.isPremium) return "active";
42
- if (doc.expirationDate) {
43
- const expDate = doc.expirationDate.toDate?.();
44
- if (expDate && expDate < new Date()) return "expired";
40
+ /** Validate subscription status against expirationDate */
41
+ private static validateSubscription(
42
+ doc: UserCreditsDocumentRead,
43
+ expirationDate: Date | null
44
+ ): { isPremium: boolean; status: SubscriptionStatus } {
45
+ const docIsPremium = doc.isPremium ?? false;
46
+
47
+ // No expiration date = lifetime or free
48
+ if (!expirationDate) {
49
+ return {
50
+ isPremium: docIsPremium,
51
+ status: docIsPremium ? "active" : "free",
52
+ };
53
+ }
54
+
55
+ // Check if subscription has expired
56
+ const isExpired = expirationDate < new Date();
57
+
58
+ if (isExpired) {
59
+ // Subscription expired - override document's isPremium
60
+ return { isPremium: false, status: "expired" };
45
61
  }
46
- return "free";
62
+
63
+ // Subscription still active
64
+ return {
65
+ isPremium: docIsPremium,
66
+ status: docIsPremium ? "active" : "free",
67
+ };
47
68
  }
48
69
  }
@@ -129,6 +129,25 @@ export class CreditsRepository extends BaseRepository {
129
129
  const res = await this.getCredits(userId);
130
130
  return !!(res.success && res.data && res.data.credits >= cost);
131
131
  }
132
+
133
+ /** Sync expired subscription status to Firestore (background) */
134
+ async syncExpiredStatus(userId: string): Promise<void> {
135
+ const db = getFirestore();
136
+ if (!db) return;
137
+
138
+ try {
139
+ const ref = this.getRef(db, userId);
140
+ const { updateDoc } = await import("firebase/firestore");
141
+ await updateDoc(ref, {
142
+ isPremium: false,
143
+ status: "expired",
144
+ lastUpdatedAt: serverTimestamp(),
145
+ });
146
+ if (__DEV__) console.log("[CreditsRepository] Synced expired status for:", userId.slice(0, 8));
147
+ } catch (e) {
148
+ if (__DEV__) console.error("[CreditsRepository] Sync expired failed:", e);
149
+ }
150
+ }
132
151
  }
133
152
 
134
153
  export const createCreditsRepository = (c: CreditsConfig) => new CreditsRepository(c);
@@ -41,16 +41,31 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
41
41
 
42
42
  configureCreditsRepository({ ...credits, creditPackageAmounts: creditPackages?.amounts });
43
43
 
44
- const onPurchase = async (userId: string, productId: string, _customerInfo: unknown, source?: string) => {
44
+ /** Extract RevenueCat data from CustomerInfo (Single Source of Truth) */
45
+ const extractRevenueCatData = (customerInfo: CustomerInfo, _productId: string): RevenueCatData => {
46
+ const entitlement = customerInfo.entitlements.active[entitlementId]
47
+ ?? customerInfo.entitlements.all[entitlementId];
48
+
49
+ return {
50
+ expirationDate: entitlement?.expirationDate ?? customerInfo.latestExpirationDate ?? null,
51
+ willRenew: entitlement?.willRenew ?? false,
52
+ originalTransactionId: entitlement?.originalPurchaseDate ?? undefined,
53
+ isPremium: Object.keys(customerInfo.entitlements.active).length > 0,
54
+ };
55
+ };
56
+
57
+ const onPurchase = async (userId: string, productId: string, customerInfo: CustomerInfo, source?: string) => {
45
58
  if (__DEV__) {
46
59
  console.log('[SubscriptionInitializer] onPurchase called:', { userId, productId, source });
47
60
  }
48
61
  try {
62
+ const revenueCatData = extractRevenueCatData(customerInfo, productId);
49
63
  const result = await getCreditsRepository().initializeCredits(
50
64
  userId,
51
65
  `purchase_${productId}_${Date.now()}`,
52
66
  productId,
53
- source as any
67
+ source as any,
68
+ revenueCatData
54
69
  );
55
70
  if (__DEV__) {
56
71
  console.log('[SubscriptionInitializer] Credits initialized:', result);
@@ -63,16 +78,20 @@ export const initializeSubscription = async (config: SubscriptionInitConfig): Pr
63
78
  }
64
79
  };
65
80
 
66
- const onRenewal = async (userId: string, productId: string, _newExpirationDate: string, _customerInfo: unknown) => {
81
+ const onRenewal = async (userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) => {
67
82
  if (__DEV__) {
68
83
  console.log('[SubscriptionInitializer] onRenewal called:', { userId, productId });
69
84
  }
70
85
  try {
86
+ const revenueCatData = extractRevenueCatData(customerInfo, productId);
87
+ // Update expiration date from renewal
88
+ revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
71
89
  const result = await getCreditsRepository().initializeCredits(
72
90
  userId,
73
91
  `renewal_${productId}_${Date.now()}`,
74
92
  productId,
75
- "renewal" as any
93
+ "renewal" as any,
94
+ revenueCatData
76
95
  );
77
96
  if (__DEV__) {
78
97
  console.log('[SubscriptionInitializer] Credits reset on renewal:', result);
@@ -88,15 +88,22 @@ export const useCredits = ({
88
88
  if (__DEV__) console.error("[useCredits] Query failed:", result.error?.message);
89
89
  throw new Error(result.error?.message || "Failed to fetch credits");
90
90
  }
91
- if (__DEV__) console.log("[useCredits] Query success:", { hasData: !!result.data, credits: result.data?.credits });
91
+
92
+ // Background sync: If mapper detected expired status, sync to Firestore
93
+ if (result.data?.status === "expired") {
94
+ if (__DEV__) console.log("[useCredits] Detected expired subscription, syncing...");
95
+ repository.syncExpiredStatus(userId).catch(() => {});
96
+ }
97
+
98
+ if (__DEV__) console.log("[useCredits] Query success:", { hasData: !!result.data, credits: result.data?.credits, status: result.data?.status });
92
99
  return result.data || null;
93
100
  },
94
101
  enabled: queryEnabled,
95
102
  staleTime,
96
103
  gcTime,
97
- refetchOnMount: true, // Refetch when component mounts
98
- refetchOnWindowFocus: true, // Refetch when app becomes active
99
- refetchOnReconnect: true, // Refetch when network reconnects
104
+ refetchOnMount: true,
105
+ refetchOnWindowFocus: true,
106
+ refetchOnReconnect: true,
100
107
  });
101
108
 
102
109
  const credits = data ?? null;
@@ -1,21 +1,16 @@
1
1
  /**
2
2
  * useSubscriptionSettingsConfig Hook
3
3
  * Returns ready-to-use config for settings screens
4
- * Package-driven: all logic handled internally
4
+ * Single Source of Truth: Firestore (credits document)
5
5
  */
6
6
 
7
7
  import { useMemo, useCallback } from "react";
8
8
  import { useCredits } from "./useCredits";
9
- import { useSubscriptionStatus } from "./useSubscriptionStatus";
10
- import { useCustomerInfo } from "../../revenuecat/presentation/hooks/useCustomerInfo";
11
9
  import { usePaywallVisibility } from "./usePaywallVisibility";
12
10
  import { calculateDaysRemaining } from "../../domain/entities/SubscriptionStatus";
13
- import { SubscriptionManager } from "../../revenuecat/infrastructure/managers/SubscriptionManager";
14
- import { formatDate, convertPurchasedAt } from "../utils/subscriptionDateUtils";
11
+ import { formatDate } from "../utils/subscriptionDateUtils";
15
12
  import { useCreditsArray, getSubscriptionStatusType } from "./useSubscriptionSettingsConfig.utils";
16
13
  import { getCreditsConfig } from "../../infrastructure/repositories/CreditsRepositoryProvider";
17
- import { detectPackageType } from "../../utils/packageTypeDetector";
18
- import { getCreditAllocation } from "../../utils/creditMapper";
19
14
  import type {
20
15
  SubscriptionSettingsConfig,
21
16
  SubscriptionStatusType,
@@ -31,105 +26,53 @@ export type {
31
26
 
32
27
  /**
33
28
  * Hook that returns ready-to-use subscription config for settings
34
- * All business logic handled internally
29
+ * Single Source of Truth: Firestore credits document
35
30
  */
36
31
  export const useSubscriptionSettingsConfig = (
37
32
  params: UseSubscriptionSettingsConfigParams
38
33
  ): SubscriptionSettingsConfig => {
39
- const {
40
- userId,
41
- translations,
42
- creditLimit,
43
- upgradePrompt,
44
- } = params;
34
+ const { userId, translations, creditLimit, upgradePrompt } = params;
45
35
 
46
- // Internal hooks
36
+ // Single Source of Truth: Firestore credits document
47
37
  const { credits } = useCredits({ userId, enabled: !!userId });
48
- const {
49
- isPremium: subscriptionActive,
50
- expirationDate: statusExpirationDate,
51
- } = useSubscriptionStatus({
52
- userId,
53
- enabled: !!userId,
54
- });
55
- const { customerInfo } = useCustomerInfo();
56
38
  const { openPaywall } = usePaywallVisibility();
57
39
 
58
40
  const handleOpenPaywall = useCallback(() => {
59
41
  openPaywall("settings");
60
42
  }, [openPaywall]);
61
43
 
62
- // RevenueCat entitlement info - dynamically using configured entitlementId
63
- const entitlementId = SubscriptionManager.getEntitlementId() || "premium";
64
- const activeEntitlement = customerInfo?.entitlements.active[entitlementId];
65
- const allEntitlement = customerInfo?.entitlements.all[entitlementId];
44
+ // All data from Firestore (Single Source of Truth)
45
+ const isPremium = credits?.isPremium ?? false;
46
+ const willRenew = credits?.willRenew ?? false;
66
47
 
67
- // Premium status: only active entitlements count as premium
68
- const isPremium = !!activeEntitlement || subscriptionActive;
48
+ // Expiration date from Firestore
49
+ const expiresAtIso = credits?.expirationDate?.toISOString() ?? null;
69
50
 
51
+ // Purchase date from Firestore
52
+ const purchasedAtIso = credits?.purchasedAt?.toISOString() ?? null;
53
+
54
+ // Credit limit from Firestore or config fallback
70
55
  const dynamicCreditLimit = useMemo(() => {
56
+ if (credits?.creditLimit) return credits.creditLimit;
71
57
  const config = getCreditsConfig();
72
-
73
- // 1. ÖNCE FIRESTORE'DAN OKU (Single Source of Truth)
74
- if (credits?.creditLimit) {
75
- return credits.creditLimit;
76
- }
77
-
78
- // 2. FALLBACK: RevenueCat'ten detect et
79
- if (activeEntitlement?.productIdentifier) {
80
- const packageType = detectPackageType(activeEntitlement.productIdentifier);
81
- const allocation = getCreditAllocation(packageType, config.packageAllocations);
82
- if (allocation !== null) return allocation;
83
- }
84
-
85
- // 3. LAST RESORT: Credit miktarına bakarak tahmin et
86
- if (credits?.credits && config.packageAllocations) {
87
- const currentCredits = credits.credits;
88
- const allocations = Object.values(config.packageAllocations).map(a => a.credits);
89
- const closest = allocations.find(a => a >= currentCredits) || Math.max(...allocations);
90
- return closest;
91
- }
92
-
93
- // 4. FINAL FALLBACK: Config'den al
94
58
  return creditLimit ?? config.creditLimit;
95
- }, [credits?.creditLimit, credits?.credits, activeEntitlement?.productIdentifier, creditLimit]);
96
-
97
- // Get expiration date with fallback chain (supports expired subscriptions)
98
- // 1. Active entitlement (current subscription)
99
- // 2. All entitlements (includes expired subscriptions)
100
- // 3. latestExpirationDate from CustomerInfo
101
- // 4. Status from Firestore
102
- const expiresAtIso = activeEntitlement?.expirationDate
103
- ?? allEntitlement?.expirationDate
104
- ?? customerInfo?.latestExpirationDate
105
- ?? (statusExpirationDate ? statusExpirationDate.toISOString() : null);
106
-
107
- const willRenew = activeEntitlement?.willRenew || false;
108
- const purchasedAtIso = convertPurchasedAt(credits?.purchasedAt);
59
+ }, [credits?.creditLimit, creditLimit]);
109
60
 
110
61
  // Formatted dates
111
- const formattedExpirationDate = useMemo(
112
- () => formatDate(expiresAtIso),
113
- [expiresAtIso]
114
- );
115
-
116
- const formattedPurchaseDate = useMemo(
117
- () => formatDate(purchasedAtIso),
118
- [purchasedAtIso]
119
- );
62
+ const formattedExpirationDate = useMemo(() => formatDate(expiresAtIso), [expiresAtIso]);
63
+ const formattedPurchaseDate = useMemo(() => formatDate(purchasedAtIso), [purchasedAtIso]);
120
64
 
121
- // Days remaining calculation
122
- const daysRemaining = useMemo(
123
- () => calculateDaysRemaining(expiresAtIso),
124
- [expiresAtIso]
125
- );
65
+ // Days remaining
66
+ const daysRemaining = useMemo(() => calculateDaysRemaining(expiresAtIso), [expiresAtIso]);
126
67
 
127
- // Status type
128
- const statusType: SubscriptionStatusType = getSubscriptionStatusType(isPremium);
68
+ // Status type from Firestore or derived
69
+ const statusType: SubscriptionStatusType = credits?.status
70
+ ? (credits.status as SubscriptionStatusType)
71
+ : getSubscriptionStatusType(isPremium);
129
72
 
130
73
  const creditsArray = useCreditsArray(credits, dynamicCreditLimit, translations);
131
74
 
132
- // Centralized display flags - single source of truth for UI visibility
75
+ // Centralized display flags
133
76
  const hasCredits = creditsArray.length > 0;
134
77
  const display = useMemo(() => ({
135
78
  showHeader: isPremium || hasCredits,
@@ -139,63 +82,47 @@ export const useSubscriptionSettingsConfig = (
139
82
  }), [isPremium, hasCredits, upgradePrompt, expiresAtIso]);
140
83
 
141
84
  // Build config
142
- const config = useMemo(
143
- (): SubscriptionSettingsConfig => ({
144
- enabled: true,
145
- settingsItem: {
146
- title: translations.title,
147
- description: translations.description,
148
- isPremium,
149
- statusLabel: isPremium
150
- ? translations.statusActive
151
- : translations.statusFree,
152
- icon: "diamond",
153
- onPress: handleOpenPaywall,
154
- },
155
- sectionConfig: {
156
- statusType,
157
- isPremium,
158
- display,
159
- expirationDate: formattedExpirationDate,
160
- purchaseDate: formattedPurchaseDate,
161
- isLifetime: isPremium && !expiresAtIso,
162
- daysRemaining,
163
- willRenew,
164
- credits: creditsArray,
165
- translations: {
166
- title: translations.title,
167
- statusLabel: translations.statusLabel,
168
- statusActive: translations.statusActive,
169
- statusExpired: translations.statusExpired,
170
- statusFree: translations.statusFree,
171
- statusCanceled: translations.statusCanceled,
172
- expiresLabel: translations.expiresLabel,
173
- purchasedLabel: translations.purchasedLabel,
174
- lifetimeLabel: translations.lifetimeLabel,
175
- creditsTitle: translations.creditsTitle,
176
- remainingLabel: translations.remainingLabel,
177
- manageButton: translations.manageButton,
178
- upgradeButton: translations.upgradeButton,
179
- },
180
- onUpgrade: handleOpenPaywall,
181
- upgradePrompt,
182
- },
183
- }),
184
- [
185
- translations,
85
+ return useMemo((): SubscriptionSettingsConfig => ({
86
+ enabled: true,
87
+ settingsItem: {
88
+ title: translations.title,
89
+ description: translations.description,
186
90
  isPremium,
91
+ statusLabel: isPremium ? translations.statusActive : translations.statusFree,
92
+ icon: "diamond",
93
+ onPress: handleOpenPaywall,
94
+ },
95
+ sectionConfig: {
187
96
  statusType,
97
+ isPremium,
188
98
  display,
189
- formattedExpirationDate,
190
- formattedPurchaseDate,
191
- expiresAtIso,
99
+ expirationDate: formattedExpirationDate,
100
+ purchaseDate: formattedPurchaseDate,
101
+ isLifetime: isPremium && !expiresAtIso,
192
102
  daysRemaining,
193
103
  willRenew,
194
- creditsArray,
195
- handleOpenPaywall,
104
+ credits: creditsArray,
105
+ translations: {
106
+ title: translations.title,
107
+ statusLabel: translations.statusLabel,
108
+ statusActive: translations.statusActive,
109
+ statusExpired: translations.statusExpired,
110
+ statusFree: translations.statusFree,
111
+ statusCanceled: translations.statusCanceled,
112
+ expiresLabel: translations.expiresLabel,
113
+ purchasedLabel: translations.purchasedLabel,
114
+ lifetimeLabel: translations.lifetimeLabel,
115
+ creditsTitle: translations.creditsTitle,
116
+ remainingLabel: translations.remainingLabel,
117
+ manageButton: translations.manageButton,
118
+ upgradeButton: translations.upgradeButton,
119
+ },
120
+ onUpgrade: handleOpenPaywall,
196
121
  upgradePrompt,
197
- ]
198
- );
199
-
200
- return config;
122
+ },
123
+ }), [
124
+ translations, isPremium, statusType, display, formattedExpirationDate,
125
+ formattedPurchaseDate, expiresAtIso, daysRemaining, willRenew,
126
+ creditsArray, handleOpenPaywall, upgradePrompt,
127
+ ]);
201
128
  };