@umituz/react-native-subscription 2.37.70 → 2.37.72

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.70",
3
+ "version": "2.37.72",
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,8 +8,9 @@ import { refundCreditsOperation } from "../application/RefundCreditsCommand";
8
8
  import { PURCHASE_TYPE, type PurchaseType } from "../../subscription/core/SubscriptionConstants";
9
9
  import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore";
10
10
  import { fetchCredits, checkHasCredits } from "./operations/CreditsFetcher";
11
- import { syncExpiredStatus, syncPremiumMetadata, type PremiumMetadata } from "./operations/CreditsWriter";
11
+ import { syncExpiredStatus, syncPremiumMetadata, createRecoveryCreditsDocument, type PremiumMetadata } from "./operations/CreditsWriter";
12
12
  import { initializeCreditsWithRetry } from "./operations/CreditsInitializer";
13
+ import { calculateCreditLimit } from "../application/CreditLimitCalculator";
13
14
 
14
15
  export class CreditsRepository extends BaseRepository {
15
16
  constructor(private config: CreditsConfig) {
@@ -79,6 +80,25 @@ export class CreditsRepository extends BaseRepository {
79
80
  const db = requireFirestore();
80
81
  await syncPremiumMetadata(this.getRef(db, userId), metadata);
81
82
  }
83
+
84
+ async ensurePremiumCreditsExist(
85
+ userId: string,
86
+ productId: string,
87
+ willRenew: boolean,
88
+ expirationDate: string | null,
89
+ periodType: string | null,
90
+ ): Promise<boolean> {
91
+ const db = requireFirestore();
92
+ const creditLimit = calculateCreditLimit(productId, this.config);
93
+ return createRecoveryCreditsDocument(
94
+ this.getRef(db, userId),
95
+ creditLimit,
96
+ productId,
97
+ willRenew,
98
+ expirationDate,
99
+ periodType,
100
+ );
101
+ }
82
102
  }
83
103
 
84
104
  export function createCreditsRepository(config: CreditsConfig): CreditsRepository {
@@ -5,6 +5,7 @@ import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionCons
5
5
  import { resolveSubscriptionStatus } from "../../../subscription/core/SubscriptionStatus";
6
6
  import { toTimestamp } from "../../../../shared/utils/dateConverter";
7
7
  import { isPast } from "../../../../utils/dateUtils";
8
+ import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
8
9
 
9
10
  export async function syncExpiredStatus(ref: DocumentReference): Promise<void> {
10
11
  const doc = await getDoc(ref);
@@ -62,3 +63,52 @@ export async function syncPremiumMetadata(
62
63
  ...(metadata.ownershipType && { ownershipType: metadata.ownershipType }),
63
64
  }, { merge: true });
64
65
  }
66
+
67
+ /**
68
+ * Recovery: creates a credits document for premium users who don't have one.
69
+ * This handles edge cases like test store purchases, reinstalls, or failed initializations.
70
+ * Returns true if a new document was created, false if one already existed.
71
+ */
72
+ export async function createRecoveryCreditsDocument(
73
+ ref: DocumentReference,
74
+ creditLimit: number,
75
+ productId: string,
76
+ willRenew: boolean,
77
+ expirationDate: string | null,
78
+ periodType: string | null,
79
+ ): Promise<boolean> {
80
+ const doc = await getDoc(ref);
81
+ if (doc.exists()) return false;
82
+
83
+ const platform = validatePlatform();
84
+ const appVersion = getAppVersion();
85
+
86
+ const isExpired = expirationDate ? isPast(expirationDate) : false;
87
+ const status = resolveSubscriptionStatus({
88
+ isPremium: true,
89
+ willRenew,
90
+ isExpired,
91
+ periodType: periodType ?? undefined,
92
+ });
93
+
94
+ const expirationTimestamp = expirationDate ? toTimestamp(expirationDate) : null;
95
+
96
+ await setDoc(ref, {
97
+ credits: creditLimit,
98
+ creditLimit,
99
+ isPremium: true,
100
+ status,
101
+ willRenew,
102
+ productId,
103
+ platform,
104
+ appVersion,
105
+ processedPurchases: [],
106
+ purchaseHistory: [],
107
+ createdAt: serverTimestamp(),
108
+ lastUpdatedAt: serverTimestamp(),
109
+ recoveryInitialized: true,
110
+ ...(expirationTimestamp && { expirationDate: expirationTimestamp }),
111
+ });
112
+
113
+ return true;
114
+ }
@@ -10,6 +10,8 @@ import { handleExpiredSubscription, handlePremiumStatusSync } from "./statusChan
10
10
  import type { PackageType } from "../../revenuecat/core/types";
11
11
 
12
12
  export class SubscriptionSyncProcessor {
13
+ private purchaseInProgress = false;
14
+
13
15
  constructor(
14
16
  private entitlementId: string,
15
17
  private getAnonymousUserId: () => Promise<string>
@@ -35,43 +37,53 @@ export class SubscriptionSyncProcessor {
35
37
  }
36
38
 
37
39
  async processPurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource, packageType?: PackageType | null) {
38
- const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
39
- revenueCatData.packageType = packageType ?? null;
40
- revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
41
- const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, productId);
42
-
43
- const creditsUserId = await this.getCreditsUserId(userId);
44
-
45
- await getCreditsRepository().initializeCredits(
46
- creditsUserId,
47
- purchaseId,
48
- productId,
49
- source ?? PURCHASE_SOURCE.SETTINGS,
50
- revenueCatData,
51
- PURCHASE_TYPE.INITIAL
52
- );
53
-
54
- emitCreditsUpdated(creditsUserId);
40
+ this.purchaseInProgress = true;
41
+ try {
42
+ const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
43
+ revenueCatData.packageType = packageType ?? null;
44
+ revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
45
+ const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, productId);
46
+
47
+ const creditsUserId = await this.getCreditsUserId(userId);
48
+
49
+ await getCreditsRepository().initializeCredits(
50
+ creditsUserId,
51
+ purchaseId,
52
+ productId,
53
+ source ?? PURCHASE_SOURCE.SETTINGS,
54
+ revenueCatData,
55
+ PURCHASE_TYPE.INITIAL
56
+ );
57
+
58
+ emitCreditsUpdated(creditsUserId);
59
+ } finally {
60
+ this.purchaseInProgress = false;
61
+ }
55
62
  }
56
63
 
57
64
  async processRenewal(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
58
- const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
59
- revenueCatData.expirationDate = newExpirationDate ?? revenueCatData.expirationDate;
60
- revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
61
- const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, productId, newExpirationDate);
62
-
63
- const creditsUserId = await this.getCreditsUserId(userId);
64
-
65
- await getCreditsRepository().initializeCredits(
66
- creditsUserId,
67
- purchaseId,
68
- productId,
69
- PURCHASE_SOURCE.RENEWAL,
70
- revenueCatData,
71
- PURCHASE_TYPE.RENEWAL
72
- );
73
-
74
- emitCreditsUpdated(creditsUserId);
65
+ this.purchaseInProgress = true;
66
+ try {
67
+ const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
68
+ revenueCatData.expirationDate = newExpirationDate ?? revenueCatData.expirationDate;
69
+ revenueCatData.revenueCatUserId = await this.getRevenueCatAppUserId();
70
+ const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, productId, newExpirationDate);
71
+
72
+ const creditsUserId = await this.getCreditsUserId(userId);
73
+
74
+ await getCreditsRepository().initializeCredits(
75
+ creditsUserId,
76
+ purchaseId,
77
+ productId,
78
+ PURCHASE_SOURCE.RENEWAL,
79
+ revenueCatData,
80
+ PURCHASE_TYPE.RENEWAL
81
+ );
82
+
83
+ emitCreditsUpdated(creditsUserId);
84
+ } finally {
85
+ this.purchaseInProgress = false;
86
+ }
75
87
  }
76
88
 
77
89
  async processStatusChange(
@@ -82,6 +94,16 @@ export class SubscriptionSyncProcessor {
82
94
  willRenew?: boolean,
83
95
  periodType?: PeriodType
84
96
  ) {
97
+ // Skip if a purchase is already handling the credits document.
98
+ // Both PurchaseExecutor and CustomerInfoListener fire after a purchase —
99
+ // the purchase handler writes credits + metadata, so the status handler can skip.
100
+ if (this.purchaseInProgress) {
101
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
102
+ console.log("[SubscriptionSyncProcessor] Skipping status change - purchase in progress");
103
+ }
104
+ return;
105
+ }
106
+
85
107
  const creditsUserId = await this.getCreditsUserId(userId);
86
108
 
87
109
  if (!isPremium && productId) {
@@ -19,7 +19,24 @@ export const handlePremiumStatusSync = async (
19
19
  store?: string | null,
20
20
  ownershipType?: string | null
21
21
  ): Promise<void> => {
22
- await getCreditsRepository().syncPremiumMetadata(userId, {
22
+ const repo = getCreditsRepository();
23
+
24
+ // Recovery: if premium user has no credits document, create one.
25
+ // Handles edge cases like test store, reinstalls, or failed purchase initialization.
26
+ if (isPremium) {
27
+ const created = await repo.ensurePremiumCreditsExist(
28
+ userId,
29
+ productId,
30
+ willRenew,
31
+ expiresAt,
32
+ periodType,
33
+ );
34
+ if (__DEV__ && created) {
35
+ console.log('[handlePremiumStatusSync] Recovery: created missing credits document for premium user', { userId, productId });
36
+ }
37
+ }
38
+
39
+ await repo.syncPremiumMetadata(userId, {
23
40
  isPremium,
24
41
  willRenew,
25
42
  expirationDate: expiresAt,