@umituz/react-native-subscription 2.39.12 → 2.40.0

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.39.12",
3
+ "version": "2.40.0",
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",
@@ -8,7 +8,7 @@ import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
8
8
  import { calculateNewCredits, buildCreditsData } from "./creditOperationUtils";
9
9
  import { calculateCreditLimit } from "./CreditLimitCalculator";
10
10
  import { generatePurchaseMetadata } from "./PurchaseMetadataGenerator";
11
- import { PURCHASE_ID_PREFIXES, GLOBAL_TRANSACTION_COLLECTION } from "../core/CreditsConstants";
11
+ import { PURCHASE_ID_PREFIXES, TRANSACTION_SUBCOLLECTION } from "../core/CreditsConstants";
12
12
 
13
13
  export async function initializeCreditsTransaction(
14
14
  _db: Firestore,
@@ -19,6 +19,20 @@ export async function initializeCreditsTransaction(
19
19
  userId: string
20
20
  ): Promise<InitializationResult> {
21
21
 
22
+ if (__DEV__) {
23
+ console.log('[CreditsInitializer] 🔵 initializeCreditsTransaction: START', {
24
+ userId,
25
+ purchaseId,
26
+ productId: metadata.productId,
27
+ source: metadata.source,
28
+ type: metadata.type,
29
+ isPremium: metadata.isPremium,
30
+ willRenew: metadata.willRenew,
31
+ storeTransactionId: metadata.storeTransactionId,
32
+ timestamp: new Date().toISOString(),
33
+ });
34
+ }
35
+
22
36
  if (!purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) && !purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL)) {
23
37
  throw new Error(`[CreditsInitializer] Only purchase and renewal operations can allocate credits. Received: ${purchaseId}`);
24
38
  }
@@ -32,6 +46,14 @@ export async function initializeCreditsTransaction(
32
46
  const existingData = getCreditDocumentOrDefault(creditsDoc, platform);
33
47
 
34
48
  if (existingData.processedPurchases.includes(purchaseId)) {
49
+ if (__DEV__) {
50
+ console.log('[CreditsInitializer] 🟡 Transaction already processed, skipping', {
51
+ userId,
52
+ purchaseId,
53
+ existingCredits: existingData.credits,
54
+ processedPurchasesCount: existingData.processedPurchases.length,
55
+ });
56
+ }
35
57
  return {
36
58
  credits: existingData.credits,
37
59
  alreadyProcessed: true,
@@ -39,26 +61,37 @@ export async function initializeCreditsTransaction(
39
61
  };
40
62
  }
41
63
 
42
- // Global cross-user deduplication: prevent the same Apple/Google transaction
43
- // from allocating credits under multiple Firebase UIDs.
64
+ // User-specific transaction deduplication: prevent the same Apple/Google transaction
65
+ // from allocating credits multiple times for the same user.
66
+ // Path: users/{userId}/credits/processedTransactions/{transactionId}
44
67
  if (metadata.storeTransactionId) {
45
- const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.storeTransactionId);
46
- const globalDoc = await transaction.get(globalRef);
47
- if (globalDoc.exists()) {
48
- const globalData = globalDoc.data();
49
- if (globalData?.ownerUserId && globalData.ownerUserId !== userId) {
50
- console.warn(
51
- `[CreditsInitializer] Transaction ${metadata.storeTransactionId} already processed by user ${globalData.ownerUserId}, skipping for ${userId}`
52
- );
53
- return {
54
- credits: existingData.credits,
55
- alreadyProcessed: true,
56
- finalData: existingData
57
- };
68
+ const transactionRef = doc(_db, creditsRef.path, TRANSACTION_SUBCOLLECTION, metadata.storeTransactionId);
69
+ const transactionDoc = await transaction.get(transactionRef);
70
+ if (transactionDoc.exists()) {
71
+ if (__DEV__) {
72
+ console.log('[CreditsInitializer] 🟡 Store transaction already processed, skipping', {
73
+ userId,
74
+ storeTransactionId: metadata.storeTransactionId,
75
+ existingCredits: existingData.credits,
76
+ });
58
77
  }
78
+ return {
79
+ credits: existingData.credits,
80
+ alreadyProcessed: true,
81
+ finalData: existingData
82
+ };
59
83
  }
60
84
  }
61
85
 
86
+ if (__DEV__) {
87
+ console.log('[CreditsInitializer] 🔵 Processing credit allocation', {
88
+ userId,
89
+ purchaseId,
90
+ existingCredits: existingData.credits,
91
+ productId: metadata.productId,
92
+ });
93
+ }
94
+
62
95
  const creditLimit = calculateCreditLimit(metadata.productId, config);
63
96
  const { purchaseHistory } = generatePurchaseMetadata({
64
97
  productId: metadata.productId,
@@ -88,17 +121,27 @@ export async function initializeCreditsTransaction(
88
121
 
89
122
  transaction.set(creditsRef, creditsData, { merge: true });
90
123
 
91
- // Register transaction globally so other UIDs cannot claim the same purchase.
124
+ // Register transaction in user-specific subcollection to prevent duplicate processing.
92
125
  if (metadata.storeTransactionId) {
93
- const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.storeTransactionId);
94
- transaction.set(globalRef, {
95
- ownerUserId: userId,
126
+ const transactionRef = doc(_db, creditsRef.path, TRANSACTION_SUBCOLLECTION, metadata.storeTransactionId);
127
+ transaction.set(transactionRef, {
96
128
  purchaseId,
97
129
  productId: metadata.productId,
98
130
  createdAt: serverTimestamp(),
99
131
  });
100
132
  }
101
133
 
134
+ if (__DEV__) {
135
+ console.log('[CreditsInitializer] 🟢 Credit allocation successful', {
136
+ userId,
137
+ purchaseId,
138
+ previousCredits: existingData.credits,
139
+ newCredits,
140
+ creditLimit,
141
+ productId: metadata.productId,
142
+ });
143
+ }
144
+
102
145
  return {
103
146
  credits: newCredits,
104
147
  alreadyProcessed: false,
@@ -16,8 +16,9 @@ export const PROCESSED_PURCHASES_WINDOW = 50;
16
16
  export const MAX_SINGLE_DEDUCTION = 10000;
17
17
 
18
18
  /**
19
- * Global Firestore collection for cross-user transaction deduplication.
20
- * Prevents the same Apple/Google transaction from allocating credits
21
- * under multiple Firebase UIDs (e.g., anonymous + converted accounts).
19
+ * User-specific Firestore sub-collection for transaction deduplication.
20
+ * Changed from global root-level collection to user-scoped collection
21
+ * to match vivoim_app pattern and avoid Firestore permission issues.
22
+ * Path: users/{userId}/credits/processedTransactions/{transactionId}
22
23
  */
23
- export const GLOBAL_TRANSACTION_COLLECTION = 'processedTransactions';
24
+ export const TRANSACTION_SUBCOLLECTION = 'processedTransactions';
@@ -7,7 +7,7 @@ import type { SubscriptionMetadata } from "../../../subscription/core/types/Subs
7
7
  import { toTimestamp } from "../../../../shared/utils/dateConverter";
8
8
  import { isPast } from "../../../../utils/dateUtils";
9
9
  import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
10
- import { GLOBAL_TRANSACTION_COLLECTION } from "../../core/CreditsConstants";
10
+ import { TRANSACTION_SUBCOLLECTION } from "../../core/CreditsConstants";
11
11
 
12
12
  // Fix: was getDoc+setDoc (non-atomic) — now uses runTransaction so concurrent
13
13
  // initializeCreditsTransaction and deductCreditsOperation no longer see stale
@@ -67,17 +67,13 @@ export async function syncPremiumMetadata(
67
67
  * This handles edge cases like test store purchases, reinstalls, or failed initializations.
68
68
  * Returns true if a new document was created, false if one already existed.
69
69
  *
70
- * Cross-user guard: if storeTransactionId is provided and already registered
71
- * to a different user in the global processedTransactions collection, the recovery
72
- * document is NOT created (the subscription belongs to another UID).
73
- *
74
70
  * NOTE: This uses non-atomic check-then-act (getDoc + setDoc). In theory, two concurrent
75
71
  * calls could both see no document and create duplicates. However, this is extremely rare
76
72
  * in practice because: (1) createRecoveryCreditsDocument is called after a successful
77
- * purchase which is already serialized, (2) the global transaction check (below) prevents
78
- * duplicates across users, (3) even if two recovery docs are created, the credits document
73
+ * purchase which is already serialized, (2) the user-specific transaction check (below) prevents
74
+ * duplicates, (3) even if two recovery docs are created, the credits document
79
75
  * logic is idempotent (same purchaseId processed twice is no-op). Making this atomic
80
- * would require a transaction spanning both the credits doc and global processedTransactions,
76
+ * would require a transaction spanning both the credits doc and transaction subcollection,
81
77
  * which adds complexity without meaningful benefit given the safeguards above.
82
78
  */
83
79
  export async function createRecoveryCreditsDocument(
@@ -94,24 +90,25 @@ export async function createRecoveryCreditsDocument(
94
90
  const existingDoc = await getDoc(ref);
95
91
  if (existingDoc.exists()) return false;
96
92
 
97
- // Cross-user deduplication: if this transaction was already processed by another
98
- // user, skip recovery to prevent double credit allocation across UIDs.
93
+ // User-specific deduplication: if this transaction was already processed
94
+ // for this user, skip recovery to prevent duplicate credit allocation.
99
95
  if (db && userId && storeTransactionId) {
100
96
  try {
101
- const globalRef = doc(db, GLOBAL_TRANSACTION_COLLECTION, storeTransactionId);
102
- const globalDoc = await getDoc(globalRef);
103
- if (globalDoc.exists()) {
104
- const globalData = globalDoc.data();
105
- if (globalData?.ownerUserId && globalData.ownerUserId !== userId) {
106
- console.warn(
107
- `[CreditsWriter] Recovery skipped: transaction ${storeTransactionId} belongs to user ${globalData.ownerUserId}, not ${userId}`
97
+ const transactionRef = doc(db, ref.path, TRANSACTION_SUBCOLLECTION, storeTransactionId);
98
+ const transactionDoc = await getDoc(transactionRef);
99
+ if (transactionDoc.exists()) {
100
+ if (__DEV__) {
101
+ console.log(
102
+ `[CreditsWriter] Recovery skipped: transaction ${storeTransactionId} already processed for user ${userId}`
108
103
  );
109
- return false;
110
104
  }
105
+ return false;
111
106
  }
112
107
  } catch (error) {
113
- // Non-fatal: if global check fails, still create recovery doc as safety net
114
- console.warn('[CreditsWriter] Global transaction check failed during recovery:', error);
108
+ // Non-fatal: if transaction check fails, still create recovery doc as safety net
109
+ if (__DEV__) {
110
+ console.warn('[CreditsWriter] Transaction check failed during recovery:', error);
111
+ }
115
112
  }
116
113
  }
117
114
 
@@ -26,52 +26,104 @@ export class SubscriptionSyncProcessor {
26
26
  // ─── Public API (replaces SubscriptionSyncService) ────────────────
27
27
 
28
28
  async handlePurchase(event: PurchaseCompletedEvent): Promise<void> {
29
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
30
+ console.log('[SubscriptionSyncProcessor] 🔵 PURCHASE START', {
31
+ userId: event.userId,
32
+ productId: event.productId,
33
+ source: event.source,
34
+ packageType: event.packageType,
35
+ timestamp: new Date().toISOString(),
36
+ });
37
+ }
29
38
  try {
30
39
  await this.processPurchase(event);
31
40
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, {
32
41
  userId: event.userId,
33
42
  productId: event.productId,
34
43
  });
44
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
45
+ console.log('[SubscriptionSyncProcessor] 🟢 PURCHASE SUCCESS', {
46
+ userId: event.userId,
47
+ productId: event.productId,
48
+ timestamp: new Date().toISOString(),
49
+ });
50
+ }
35
51
  } catch (error) {
36
- console.error('[SubscriptionSyncProcessor] Purchase processing failed', {
52
+ console.error('[SubscriptionSyncProcessor] 🔴 PURCHASE FAILED', {
37
53
  userId: event.userId,
38
54
  productId: event.productId,
39
55
  error: error instanceof Error ? error.message : String(error),
56
+ timestamp: new Date().toISOString(),
40
57
  });
41
58
  throw error;
42
59
  }
43
60
  }
44
61
 
45
62
  async handleRenewal(event: RenewalDetectedEvent): Promise<void> {
63
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
64
+ console.log('[SubscriptionSyncProcessor] 🔵 RENEWAL START', {
65
+ userId: event.userId,
66
+ productId: event.productId,
67
+ newExpirationDate: event.newExpirationDate,
68
+ timestamp: new Date().toISOString(),
69
+ });
70
+ }
46
71
  try {
47
72
  await this.processRenewal(event);
48
73
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, {
49
74
  userId: event.userId,
50
75
  productId: event.productId,
51
76
  });
77
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
78
+ console.log('[SubscriptionSyncProcessor] 🟢 RENEWAL SUCCESS', {
79
+ userId: event.userId,
80
+ productId: event.productId,
81
+ timestamp: new Date().toISOString(),
82
+ });
83
+ }
52
84
  } catch (error) {
53
- console.error('[SubscriptionSyncProcessor] Renewal processing failed', {
85
+ console.error('[SubscriptionSyncProcessor] 🔴 RENEWAL FAILED', {
54
86
  userId: event.userId,
55
87
  productId: event.productId,
56
88
  error: error instanceof Error ? error.message : String(error),
89
+ timestamp: new Date().toISOString(),
57
90
  });
58
91
  throw error;
59
92
  }
60
93
  }
61
94
 
62
95
  async handlePremiumStatusChanged(event: PremiumStatusChangedEvent): Promise<void> {
96
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
97
+ console.log('[SubscriptionSyncProcessor] 🔵 STATUS CHANGE START', {
98
+ userId: event.userId,
99
+ isPremium: event.isPremium,
100
+ productId: event.productId,
101
+ willRenew: event.willRenew,
102
+ expirationDate: event.expirationDate,
103
+ timestamp: new Date().toISOString(),
104
+ });
105
+ }
63
106
  try {
64
107
  await this.processStatusChange(event);
65
108
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, {
66
109
  userId: event.userId,
67
110
  isPremium: event.isPremium,
68
111
  });
112
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
113
+ console.log('[SubscriptionSyncProcessor] 🟢 STATUS CHANGE SUCCESS', {
114
+ userId: event.userId,
115
+ isPremium: event.isPremium,
116
+ productId: event.productId,
117
+ timestamp: new Date().toISOString(),
118
+ });
119
+ }
69
120
  } catch (error) {
70
- console.error('[SubscriptionSyncProcessor] Status change processing failed', {
121
+ console.error('[SubscriptionSyncProcessor] 🔴 STATUS CHANGE FAILED', {
71
122
  userId: event.userId,
72
123
  isPremium: event.isPremium,
73
124
  productId: event.productId,
74
125
  error: error instanceof Error ? error.message : String(error),
126
+ timestamp: new Date().toISOString(),
75
127
  });
76
128
  throw error;
77
129
  }
@@ -96,6 +148,14 @@ export class SubscriptionSyncProcessor {
96
148
 
97
149
  private async processPurchase(event: PurchaseCompletedEvent): Promise<void> {
98
150
  this.purchaseInProgress = true;
151
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
152
+ console.log('[SubscriptionSyncProcessor] 🔵 processPurchase: Starting credit initialization', {
153
+ productId: event.productId,
154
+ source: event.source,
155
+ packageType: event.packageType,
156
+ activeEntitlements: Object.keys(event.customerInfo.entitlements.active),
157
+ });
158
+ }
99
159
  try {
100
160
  const revenueCatData = extractRevenueCatData(event.customerInfo, this.entitlementId);
101
161
  revenueCatData.packageType = event.packageType ?? null;
@@ -105,6 +165,15 @@ export class SubscriptionSyncProcessor {
105
165
 
106
166
  const creditsUserId = await this.getCreditsUserId(event.userId);
107
167
 
168
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
169
+ console.log('[SubscriptionSyncProcessor] 🔵 processPurchase: Calling initializeCredits', {
170
+ creditsUserId,
171
+ purchaseId,
172
+ productId: event.productId,
173
+ revenueCatUserId: revenueCatData.revenueCatUserId,
174
+ });
175
+ }
176
+
108
177
  const result = await getCreditsRepository().initializeCredits(
109
178
  creditsUserId,
110
179
  purchaseId,
@@ -119,6 +188,13 @@ export class SubscriptionSyncProcessor {
119
188
  }
120
189
 
121
190
  this.emitCreditsUpdated(creditsUserId);
191
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
192
+ console.log('[SubscriptionSyncProcessor] 🟢 processPurchase: Credits initialized successfully', {
193
+ creditsUserId,
194
+ purchaseId,
195
+ credits: result.data?.credits,
196
+ });
197
+ }
122
198
  } finally {
123
199
  this.purchaseInProgress = false;
124
200
  }
@@ -198,6 +274,15 @@ export class SubscriptionSyncProcessor {
198
274
  private async syncPremiumStatus(userId: string, event: PremiumStatusChangedEvent): Promise<void> {
199
275
  const repo = getCreditsRepository();
200
276
 
277
+ if (__DEV__) {
278
+ console.log('[SubscriptionSyncProcessor] 🔵 syncPremiumStatus: Starting', {
279
+ userId,
280
+ isPremium: event.isPremium,
281
+ productId: event.productId,
282
+ willRenew: event.willRenew,
283
+ });
284
+ }
285
+
201
286
  if (event.isPremium) {
202
287
  const created = await repo.ensurePremiumCreditsExist(
203
288
  userId,
@@ -208,7 +293,7 @@ export class SubscriptionSyncProcessor {
208
293
  event.storeTransactionId,
209
294
  );
210
295
  if (__DEV__ && created) {
211
- console.log('[SubscriptionSyncProcessor] Recovery: created missing credits document for premium user', {
296
+ console.log('[SubscriptionSyncProcessor] 🟢 Recovery: created missing credits document for premium user', {
212
297
  userId,
213
298
  productId: event.productId,
214
299
  });
@@ -227,6 +312,14 @@ export class SubscriptionSyncProcessor {
227
312
  ownershipType: event.ownershipType ?? null,
228
313
  });
229
314
  this.emitCreditsUpdated(userId);
315
+
316
+ if (__DEV__) {
317
+ console.log('[SubscriptionSyncProcessor] 🟢 syncPremiumStatus: Completed', {
318
+ userId,
319
+ isPremium: event.isPremium,
320
+ productId: event.productId,
321
+ });
322
+ }
230
323
  }
231
324
 
232
325
  private emitCreditsUpdated(userId: string): void {
@@ -57,7 +57,7 @@ async function executeSubscriptionPurchase(
57
57
  const source = savedPurchase?.source;
58
58
 
59
59
  if (typeof __DEV__ !== "undefined" && __DEV__) {
60
- console.log("[PurchaseExecutor] executeSubscriptionPurchase:", {
60
+ console.log("[PurchaseExecutor] 🔵 executeSubscriptionPurchase: START", {
61
61
  userId,
62
62
  productId,
63
63
  isPremium,
@@ -65,13 +65,27 @@ async function executeSubscriptionPurchase(
65
65
  activeEntitlements: Object.keys(customerInfo.entitlements.active),
66
66
  source,
67
67
  packageType,
68
+ timestamp: new Date().toISOString(),
68
69
  });
69
70
  }
70
71
 
71
72
  try {
72
73
  await notifyPurchaseCompleted(config, userId, productId, customerInfo, source, packageType);
74
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
75
+ console.log("[PurchaseExecutor] 🟢 executeSubscriptionPurchase: SUCCESS", {
76
+ userId,
77
+ productId,
78
+ isPremium,
79
+ timestamp: new Date().toISOString(),
80
+ });
81
+ }
73
82
  } catch (syncError) {
74
- console.error('[PurchaseExecutor] Post-purchase sync failed, attempting recovery:', syncError);
83
+ console.error('[PurchaseExecutor] 🔴 Post-purchase sync failed, attempting recovery:', {
84
+ userId,
85
+ productId,
86
+ error: syncError instanceof Error ? syncError.message : String(syncError),
87
+ timestamp: new Date().toISOString(),
88
+ });
75
89
  await attemptRecovery(config, userId, customerInfo);
76
90
  } finally {
77
91
  if (savedPurchase) {