@umituz/react-native-subscription 2.37.102 → 2.37.104

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.37.102",
3
+ "version": "2.37.104",
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,19 +2,21 @@ import type { CreditsConfig } from "../core/Credits";
2
2
  import { getAppVersion, validatePlatform } from "../../../utils/appUtils";
3
3
 
4
4
  import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
5
- import { runTransaction, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
5
+ import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
6
+ import { doc } from "firebase/firestore";
6
7
  import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
7
8
  import { calculateNewCredits, buildCreditsData } from "./creditOperationUtils";
8
9
  import { calculateCreditLimit } from "./CreditLimitCalculator";
9
10
  import { generatePurchaseMetadata } from "./PurchaseMetadataGenerator";
10
- import { PURCHASE_ID_PREFIXES } from "../core/CreditsConstants";
11
+ import { PURCHASE_ID_PREFIXES, GLOBAL_TRANSACTION_COLLECTION } from "../core/CreditsConstants";
11
12
 
12
13
  export async function initializeCreditsTransaction(
13
14
  _db: Firestore,
14
15
  creditsRef: DocumentReference,
15
16
  config: CreditsConfig,
16
17
  purchaseId: string,
17
- metadata: InitializeCreditsMetadata
18
+ metadata: InitializeCreditsMetadata,
19
+ userId: string
18
20
  ): Promise<InitializationResult> {
19
21
 
20
22
  if (!purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) && !purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL)) {
@@ -37,6 +39,26 @@ export async function initializeCreditsTransaction(
37
39
  };
38
40
  }
39
41
 
42
+ // Global cross-user deduplication: prevent the same Apple/Google transaction
43
+ // from allocating credits under multiple Firebase UIDs.
44
+ if (metadata.originalTransactionId) {
45
+ const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.originalTransactionId);
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.originalTransactionId} already processed by user ${globalData.ownerUserId}, skipping for ${userId}`
52
+ );
53
+ return {
54
+ credits: existingData.credits,
55
+ alreadyProcessed: true,
56
+ finalData: existingData
57
+ };
58
+ }
59
+ }
60
+ }
61
+
40
62
  const creditLimit = calculateCreditLimit(metadata.productId, config);
41
63
  const { purchaseHistory } = generatePurchaseMetadata({
42
64
  productId: metadata.productId,
@@ -66,6 +88,17 @@ export async function initializeCreditsTransaction(
66
88
 
67
89
  transaction.set(creditsRef, creditsData, { merge: true });
68
90
 
91
+ // Register transaction globally so other UIDs cannot claim the same purchase.
92
+ if (metadata.originalTransactionId) {
93
+ const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.originalTransactionId);
94
+ transaction.set(globalRef, {
95
+ ownerUserId: userId,
96
+ purchaseId,
97
+ productId: metadata.productId,
98
+ createdAt: serverTimestamp(),
99
+ });
100
+ }
101
+
69
102
  return {
70
103
  credits: newCredits,
71
104
  alreadyProcessed: false,
@@ -11,3 +11,10 @@ export const PURCHASE_ID_PREFIXES = {
11
11
  } as const;
12
12
 
13
13
  export const PROCESSED_PURCHASES_WINDOW = 50;
14
+
15
+ /**
16
+ * Global Firestore collection for cross-user transaction deduplication.
17
+ * Prevents the same Apple/Google transaction from allocating credits
18
+ * under multiple Firebase UIDs (e.g., anonymous + converted accounts).
19
+ */
20
+ export const GLOBAL_TRANSACTION_COLLECTION = 'processedTransactions';
@@ -92,6 +92,7 @@ export class CreditsRepository extends BaseRepository {
92
92
  willRenew: boolean,
93
93
  expirationDate: string | null,
94
94
  periodType: string | null,
95
+ originalTransactionId?: string | null,
95
96
  ): Promise<boolean> {
96
97
  const db = requireFirestore();
97
98
  const creditLimit = calculateCreditLimit(productId, this.config);
@@ -102,6 +103,9 @@ export class CreditsRepository extends BaseRepository {
102
103
  willRenew,
103
104
  expirationDate,
104
105
  periodType,
106
+ db,
107
+ userId,
108
+ originalTransactionId,
105
109
  );
106
110
  }
107
111
  }
@@ -43,7 +43,7 @@ function isTransientError(error: unknown): boolean {
43
43
  }
44
44
 
45
45
  export async function initializeCreditsWithRetry(params: InitializeCreditsParams): Promise<CreditsResult> {
46
- const { db, ref, config, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
46
+ const { db, ref, config, userId, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
47
47
 
48
48
  const creditLimit = calculateCreditLimit(productId, config);
49
49
  const cfg = { ...config, creditLimit };
@@ -72,7 +72,8 @@ export async function initializeCreditsWithRetry(params: InitializeCreditsParams
72
72
  ownershipType: revenueCatData.ownershipType,
73
73
  revenueCatUserId: revenueCatData.revenueCatUserId,
74
74
  type,
75
- }
75
+ },
76
+ userId
76
77
  );
77
78
 
78
79
  return {
@@ -1,11 +1,12 @@
1
- import type { DocumentReference, Transaction } from "@umituz/react-native-firebase";
1
+ import type { DocumentReference, Transaction, Firestore } from "@umituz/react-native-firebase";
2
2
  import { runTransaction, serverTimestamp } from "@umituz/react-native-firebase";
3
- import { getDoc, setDoc } from "firebase/firestore";
3
+ import { doc, getDoc, setDoc } from "firebase/firestore";
4
4
  import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionConstants";
5
5
  import { resolveSubscriptionStatus } from "../../../subscription/core/SubscriptionStatus";
6
6
  import { toTimestamp } from "../../../../shared/utils/dateConverter";
7
7
  import { isPast } from "../../../../utils/dateUtils";
8
8
  import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
9
+ import { GLOBAL_TRANSACTION_COLLECTION } from "../../core/CreditsConstants";
9
10
 
10
11
  // Fix: was getDoc+setDoc (non-atomic) — now uses runTransaction so concurrent
11
12
  // initializeCreditsTransaction and deductCreditsOperation no longer see stale
@@ -76,6 +77,10 @@ export async function syncPremiumMetadata(
76
77
  * Recovery: creates a credits document for premium users who don't have one.
77
78
  * This handles edge cases like test store purchases, reinstalls, or failed initializations.
78
79
  * Returns true if a new document was created, false if one already existed.
80
+ *
81
+ * Cross-user guard: if originalTransactionId is provided and already registered
82
+ * to a different user in the global processedTransactions collection, the recovery
83
+ * document is NOT created (the subscription belongs to another UID).
79
84
  */
80
85
  export async function createRecoveryCreditsDocument(
81
86
  ref: DocumentReference,
@@ -84,9 +89,33 @@ export async function createRecoveryCreditsDocument(
84
89
  willRenew: boolean,
85
90
  expirationDate: string | null,
86
91
  periodType: string | null,
92
+ db?: Firestore,
93
+ userId?: string,
94
+ originalTransactionId?: string | null,
87
95
  ): Promise<boolean> {
88
- const doc = await getDoc(ref);
89
- if (doc.exists()) return false;
96
+ const existingDoc = await getDoc(ref);
97
+ if (existingDoc.exists()) return false;
98
+
99
+ // Cross-user deduplication: if this transaction was already processed by another
100
+ // user, skip recovery to prevent double credit allocation across UIDs.
101
+ if (db && userId && originalTransactionId) {
102
+ try {
103
+ const globalRef = doc(db, GLOBAL_TRANSACTION_COLLECTION, originalTransactionId);
104
+ const globalDoc = await getDoc(globalRef);
105
+ if (globalDoc.exists()) {
106
+ const globalData = globalDoc.data();
107
+ if (globalData?.ownerUserId && globalData.ownerUserId !== userId) {
108
+ console.warn(
109
+ `[CreditsWriter] Recovery skipped: transaction ${originalTransactionId} belongs to user ${globalData.ownerUserId}, not ${userId}`
110
+ );
111
+ return false;
112
+ }
113
+ }
114
+ } catch (error) {
115
+ // Non-fatal: if global check fails, still create recovery doc as safety net
116
+ console.warn('[CreditsWriter] Global transaction check failed during recovery:', error);
117
+ }
118
+ }
90
119
 
91
120
  const platform = validatePlatform();
92
121
  const appVersion = getAppVersion();
@@ -11,7 +11,8 @@ export interface RevenueCatConfig {
11
11
  productId?: string,
12
12
  expiresAt?: string,
13
13
  willRenew?: boolean,
14
- periodType?: string
14
+ periodType?: string,
15
+ originalTransactionId?: string
15
16
  ) => Promise<void> | void;
16
17
  onPurchaseCompleted?: (
17
18
  userId: string,
@@ -211,13 +211,17 @@ export async function handleInitialConfiguration(
211
211
  );
212
212
 
213
213
  if (premiumEntitlement) {
214
+ const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
215
+ const originalTransactionId = subscription?.storeTransactionId ?? undefined;
216
+
214
217
  await deps.config.onPremiumStatusChanged(
215
218
  normalizedUserId,
216
219
  true,
217
220
  premiumEntitlement.productIdentifier,
218
221
  premiumEntitlement.expirationDate ?? undefined,
219
222
  premiumEntitlement.willRenew,
220
- premiumEntitlement.periodType as PeriodType | undefined
223
+ premiumEntitlement.periodType as PeriodType | undefined,
224
+ originalTransactionId
221
225
  );
222
226
  } else {
223
227
  await deps.config.onPremiumStatusChanged(
@@ -226,6 +230,7 @@ export async function handleInitialConfiguration(
226
230
  undefined,
227
231
  undefined,
228
232
  undefined,
233
+ undefined,
229
234
  undefined
230
235
  );
231
236
  }
@@ -106,7 +106,8 @@ export class SubscriptionSyncProcessor {
106
106
  productId?: string,
107
107
  expiresAt?: string,
108
108
  willRenew?: boolean,
109
- periodType?: PeriodType
109
+ periodType?: PeriodType,
110
+ originalTransactionId?: string
110
111
  ) {
111
112
  // If a purchase is in progress, skip metadata sync (purchase handler does it)
112
113
  // but still allow recovery to run — the purchase handler's credit initialization
@@ -123,7 +124,12 @@ export class SubscriptionSyncProcessor {
123
124
  productId,
124
125
  expiresAt ?? null,
125
126
  willRenew ?? false,
126
- periodType ?? null
127
+ periodType ?? null,
128
+ undefined,
129
+ undefined,
130
+ undefined,
131
+ undefined,
132
+ originalTransactionId
127
133
  );
128
134
  }
129
135
  return;
@@ -157,7 +163,12 @@ export class SubscriptionSyncProcessor {
157
163
  productId,
158
164
  expiresAt ?? null,
159
165
  willRenew ?? false,
160
- periodType ?? null
166
+ periodType ?? null,
167
+ undefined,
168
+ undefined,
169
+ undefined,
170
+ undefined,
171
+ originalTransactionId
161
172
  );
162
173
  }
163
174
  }
@@ -49,10 +49,11 @@ export class SubscriptionSyncService {
49
49
  productId?: string,
50
50
  expiresAt?: string,
51
51
  willRenew?: boolean,
52
- periodType?: PeriodType
52
+ periodType?: PeriodType,
53
+ originalTransactionId?: string
53
54
  ) {
54
55
  try {
55
- await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType);
56
+ await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType, originalTransactionId);
56
57
  subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
57
58
  } catch (error) {
58
59
  console.error('[SubscriptionSyncService] Status change processing failed', {
@@ -48,10 +48,11 @@ export function configureServices(config: SubscriptionInitConfig, apiKey: string
48
48
  pId?: string,
49
49
  exp?: string,
50
50
  willR?: boolean,
51
- pt?: string
51
+ pt?: string,
52
+ txnId?: string
52
53
  ) => {
53
54
  const validPeriodType = pt && Object.values(PERIOD_TYPE).includes(pt as PeriodType) ? pt as PeriodType : undefined;
54
- return syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, validPeriodType);
55
+ return syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, validPeriodType, txnId);
55
56
  },
56
57
  onCreditsUpdated,
57
58
  },
@@ -17,12 +17,15 @@ export const handlePremiumStatusSync = async (
17
17
  unsubscribeDetectedAt?: string | null,
18
18
  billingIssueDetectedAt?: string | null,
19
19
  store?: string | null,
20
- ownershipType?: string | null
20
+ ownershipType?: string | null,
21
+ originalTransactionId?: string
21
22
  ): Promise<void> => {
22
23
  const repo = getCreditsRepository();
23
24
 
24
25
  // Recovery: if premium user has no credits document, create one.
25
26
  // Handles edge cases like test store, reinstalls, or failed purchase initialization.
27
+ // The originalTransactionId is passed for cross-user deduplication: if this
28
+ // transaction was already processed by another UID, recovery is skipped.
26
29
  if (isPremium) {
27
30
  const created = await repo.ensurePremiumCreditsExist(
28
31
  userId,
@@ -30,6 +33,7 @@ export const handlePremiumStatusSync = async (
30
33
  willRenew,
31
34
  expiresAt,
32
35
  periodType,
36
+ originalTransactionId,
33
37
  );
34
38
  if (__DEV__ && created) {
35
39
  console.log('[handlePremiumStatusSync] Recovery: created missing credits document for premium user', { userId, productId });
@@ -28,16 +28,20 @@ export async function syncPremiumStatus(
28
28
 
29
29
  try {
30
30
  if (premiumEntitlement) {
31
+ const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
32
+ const originalTransactionId = subscription?.storeTransactionId ?? undefined;
33
+
31
34
  await config.onPremiumStatusChanged(
32
35
  userId,
33
36
  true,
34
37
  premiumEntitlement.productIdentifier,
35
38
  premiumEntitlement.expirationDate ?? undefined,
36
39
  premiumEntitlement.willRenew,
37
- premiumEntitlement.periodType as "NORMAL" | "INTRO" | undefined
40
+ premiumEntitlement.periodType as "NORMAL" | "INTRO" | undefined,
41
+ originalTransactionId
38
42
  );
39
43
  } else {
40
- await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined);
44
+ await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined, undefined);
41
45
  }
42
46
  return { success: true };
43
47
  } catch (error) {